Working notes for the vrChristmas rebuild. Raw, unedited, and addresses refer to vrxmas30.scr as loaded at 0x400000. # vrxmas30.scr scene logic — working notes (from Ghidra decompile) CAVEAT: numeric constants, state numbers and branch conditions are read directly from the decompile; the semantic labels (leader, ornament, "special move", etc.) are my inferences and are unverified against the reference video. Source: decomp/vrxmas30.c (Ghidra 12.2, i386, image base 0x400000). Addresses below are VAs. Built on the DirectX SDK d3dapp framework + D3D Retained Mode (d3drm.dll). Assets are PE resources (types XFILE / BITMAP / MIDI / AVI), extracted to assets/ by tools/pe_extract.py. ## Settings (registry HKCU\Software\VRMan\VRChristmas2\CurrentVersion), FUN_00406d80 Defaults when value missing: NumPlanes(ornament count)=2 (dialog default writes 20), NumLightStrings=3, NumLightsPerString=10, Flocking=0x100, SnowSize=0x19, MipMaps=1, Transparency=1, PlayMusic=1, BlinkLights=1, ColoredLights=1, IgnoreMouse=0, Dither, RenderType(1=Gouraud), TextureFilter, Device, Driver, Mode. Driver "Ramp Emulation" is the fallback. Transparency=0 also disables mipmaps. ## Entity ("plane"/ornament) array, FUN_00402710 = per-frame update of all entities * Count = DAT_0042c084 (NumPlanes). Struct stride = 0x78 bytes (0x1e dwords) at DAT_00430380. Fields seen (dword index from DAT_00430380): - [0] -> object whose +8 is the D3DRM frame - [2],[3],[4] (0x43038? region) per-entity velocity/offset floats (DAT_00430388/8c/90) - [0x0d] DAT_0043039c: countdown timer for state 9 (blinking/"showing off" animation) - [0x0e]/[0x0f] DAT_004303a0/a4 : trail/effect flags set when entering state 9 - DAT_004303cc: entity KIND (0 = leader/"star", 3 = ornament type with blink lights, 10 = special/spinner) - DAT_004303d0: STATE (0 follow a buddy, 3 free flocking, 5 chase/leader, 6 parked, 7 collision tumble, 8 return-home, 9 timed special move) - DAT_004303d4: index of buddy being followed (state 0) - DAT_004303d8, DAT_004303dc (countdown; <200 while state 3 => state 8), e4/e8/ec/f0: blink-light animation counters * Collision: two entities closer than 25.0 units (FUN_00402710 line ~260) both enter state 7, get random rotation (axis components in {-1,0}, speed ((rand&3)+1)*0.04), and trigger a shock effect (FUN_004019a0, SHOCK.x/bmp). * Boundaries: world floor y threshold DAT_0042d41c (terrain height); below it the entity is lifted (FUN_00402500) or, if kind state 5/8, clamped to y = DAT_0042d41c. Box clamps +-12 units for state-7 drift, 1000.0 and -400.0 used for altitude/home. * Speed update each frame (state-independent): rand&0x7f < 0x2d (45/128) => speed *= 0.988; else rand&0x7f < 0x29 (41) and speed < 50 => speed += 0.04; else if speed < 150 => speed += 0.17. Kind 10 in state 8 is capped at 8.0. * Special-move table (state 9, picked with rand()%9 each time a state-3 entity rolls rand&0xfff >= 0xff1, or >= 0xfd8 for kind 3): sets timer DAT_0043039c (range per case) and a D3DRM angular velocity (axis x,y,z, rate = ((rand&0x1ff)+0x200)*k with k in {1e-4, 1.1111e-4, 3.33e-5, 1.6667e-4, 3.333e-4}). Timer ranges: case0 30..99, 1 30..69, 2 130..169, 3 30..69, 4 50..89, 5 30..99, 6 30..69, 7 20..39, 8 40..79. When the timer expires: state back to 3, rotation cleared. * Flocking: if (0x400 - Flocking) < (rand & 0x3ff) and state==3, copy position of a random state-3 entity's target frame (DAT_00430394) into this entity's target. * Blink lights (kind 3 and BlinkLights): colour index e4 advances (0..4) when rand&0x3ff > 0x3fc; frame counter ec cycles to f0 then e8 steps 0..3. ## Camera views, DAT_004260d0 (name table at 0x426220..0x42627c) 0 Closeup, 1 Stationary, 2 Tracking, 3 SlowTrack, 4 Still, 5 Trailing, 6 Chase, 7 Target, 8 Cockpit. View switches in FUN_00402710 header: if the followed entity is parked (state 6) and rand&0x7ff > 0x7f3, pick another non-parked entity (view 5 falls back to 2 if its frame has no parent). ## Scene builder = FUN_0040bfb0 (≈1370 lines): loads XFILE resources via name strings (TRAINENG @0x428f24, CABIN @0x429064, CABINROOF @0x429050, CABINWINDOWS/2 @0x429040/0x429030, ...). NOT yet decoded. ## TODO (not yet decoded) FUN_004010a0, 00401260, 004015b0 (per-entity visuals), 004019a0 (shock/spark), 00401df0, 00401f20 (state 5/8 handlers), 00402500 (terrain avoid), 0040ac10 (pick target), 0040af20 (render/camera frame), FUN_0040bfb0 (scene build: object placement, train path, sled, snowman, cabin, intro/AVI, timing). ## Logging proxy d3drm.dll (proxy/proxy.c, build with proxy/build.bat; VS BuildTools x86) Run: run-original/ has the proxy as d3drm.dll and the real one as d3drm_real.dll; launch `vrxmas30.scr /s`. Writes d3dlog.txt (every D3DRM COM call: time, hook id, 8 raw arg dwords, string/struct hints) and d3dhooks.txt (vtable RVA -> hook id range). tools/parse_log.py -> run-original/events.json. Verified frame vtable RVA 0x4f4a0: slot 24 GetPosition, 60 SetOrientation, 61 SetPosition (a0=frame, a1=ref frame, a2..a4 = floats), 62 SetRotation, 18 AddVisual; mesh-builder vtable 0x4fa80 slot 11 = Load (name in r1="..."). First 185 s capture: 4115 rendered frames (~22 fps software), 1.01M calls, 255 frames with visuals. Still to do: resolve cloned meshes (pointer->name), build per-frame trajectories (train, sled, planes), camera path. ## Findings from the proxy capture (spec/static_frames.json, run-original/events.json) * SIMULATION IS TICK-BASED, NOT TIME-BASED: the train advances ~7.9 units per tick regardless of wall-clock dt (correlation with dt = 0.08). On this machine (software render, 1600x1200) ticks ran ~4.4/s (mean 0.226 s). Original speed on 1999-2002 hardware therefore depended on frame rate; "exact" timing needs a chosen tick rate (see TPS display string "%ld tps" in the binary) � decide with the user. * Static placements (x,y,z): LAND (0,-25,0); PINEUP, TOPSTAR, GIFTS, WEBPAGE (-400,0,0); CARD01/CARD02 (-210,40,0); CABIN, CABINROOF, CABINWINDOWS (4000,0,300); a camera-like frame (5000,16384,-1000) with orientation dir (0,-1,0) up (1,0,0). Sixteen pooled effect frames (SHOCK etc.) parked at (100,-100,100). * Dynamic entities (842 position updates each over the 191 s capture): TRAINENG + 4 TRAINBX1 cars (path starts at (2500,4,2600), y fixed at 4.0, heading follows path), plus ornament-planes: WREATH, STAR2ORN, CANDCANE, GBREAD, FISH, SLED, SNOWMANORN, ORN01, NOEL, STARORN, STAR3DB, and light strings (LIGHTSLR/P/Y/B) and SMOKE02 puff frames. 271 frames receive per-tick position updates. ## Replay viewer (remake-exact/, three.js) — first visual match * tools/xparse.py converts the binary .x models to JSON (assets-json/, all 38 parse). Textures: assets/BITMAP -> remake-exact/public/tex/*.png. * spec/model_scales.json = builder Scale() per model (LAND 222, PINEUP/TOPSTAR/GIFTS 8, CARD*/WEBPAGE 7, CABIN* 18, TRAINENG/BX1 0.25, WREATH 0.25, STAR2ORN 3, CANDCANE 0.6, GBREAD 0.3, SMOKE02 0.5, FISH 0.2, SLED 0.2, ...). * Camera = frame 02ca2e54 (child of scene root): positioned by Frame::SetPosition (slot 61) and aimed every tick with Frame::LookAt (slot 36: target frame, ref=root, constraint 0), NOT SetOrientation. Intro camera sits at (0,15,-100) looking at the CARD01 frame until t≈28 s (≈120 ticks at the captured rate), then starts moving. * Viewport field assumed D3DRM default 0.5 (horizontal half-width at front plane 1) => vertical FOV = 2*atan(0.5*H/W). * D3D (left-handed) -> three.js: negate z of vertices, positions, directions; keep winding. * Run: npm run build equivalent `npx esbuild src/main.js --bundle --format=esm --outfile=public/main.js`, serve public/ (python -m http.server 8777), open index.html?tick=N. Replays the recorded run from tracks.json by tick index. * Limits so far: orientation/rotation (SetRotation spin), light strings, smoke, flakes, lighting model, sky/fog not replicated; uses recorded tracks (replay), not yet a re-implemented simulation. ## Corrections / new findings (visual-match phase) * TICK STRUCTURE: exactly 8 rendered frames per simulation tick (506/508 intervals). Image is static between ticks (verified by frame differencing). Each tick the app calls scene Frame::Move(7.5) (slot 37, delta 0x40f00000) so SetRotation spins advance by theta*7.5 per tick. So tick rate = render_fps / 8: ~4-5/s on the capture machine (render ~40 fps); my earlier "30 ticks/s" choice would equal 240 fps in original terms. NEEDS A DECISION. * INTRO CAMERA = D3DRM animations (class 0004ff48: slot 11 create/flags 0x29 = POSITION|SPLINEPOSITION|CLOSED, slot 13 AddPositionKey(time,x,y,z), slot 16 SetFrame(camera), slot 17 SetTime). Two candidate paths, one chosen: A: t0 (-2593,371,333) t2 (1200,800,-1500) t4 (200,150,-200) t12 (-70,50,0) B: t0 (-461,344,-1672) t2 (1200,800,-1500) t4 (-100,125,-500) t12 (-400,50,-300) The camera's SetPosition events are NOT the camera motion; the true per-tick position comes from the app's own Frame::GetPosition (slot 24) results (logged as post-call pN= values). Viewer tracks use SetPosition+GetPosition ('wp'). * Lights: factory CreateLightRGB (slot 12): spot(type2)=0.2 grey, directional(type3)=0.1, ambient(type0)=0.3; a light frame at (5000,16384,-1000) does LookAt(PINEUP frame). Point lights (type 1, colour 0) are created for the colored-light FX. * The tree (PINEUP) renders translucent in the original (ground visible through it). * Slot map for IDirect3DRMFrame vtable 0x4f4a0 verified: 18 AddVisual, 24 GetPosition, 31 GetOrientation, 36 LookAt, 37 Move, 60 SetOrientation, 61 SetPosition, 62 SetRotation. * Sync method: proxy log header "T0 " (DLL load) + ffmpeg wall-clock start => video time v ~ log time T-2.1..2.4 s. ## Visual tuning result (replay viewer defaults, remake-exact/src/main.js) Fitted against a synchronized original capture (frames-original/sync1.mp4 + run-original log): time offset 2.5 s, ambient 0.8, directional 2 (from light frame at (5000,16384,-1000) aimed at the tree), tree (PINEUP) opacity 0.35 with emissive 1.6 (translucent bright lime), gifts colour x0.4, ground texture magFilter NEAREST (blocky like original). Mean abs error vs original over 13 intro frames: 33 -> ~26 (0-255 scale). Side-by-side sheets look close in framing, gift/tree/card placement. Known remaining gaps: cabin window glow, tree brightness, startup timing of the first 0.5 s, sky/stars/snow flakes, ornament spin, light strings, smoke, and everything after the intro (camera mode logic). Tools: tools/tune_driver.py (batched headless-Chrome tuning), tools/runjob.sh, tools/savesrv.py (server on 8792), tools/export_tracks.py (log -> tracks.json/meta.json). Jobs must use <=4 reference frames per batch (larger lists hang headless Chrome). ## Simulation re-implementation (remake-exact/src/sim.js; validate with `node tools/validate_sim.mjs`) Viewer: index.html?sim=1&seed=N&tps=30 (tps default 30 per user choice; original = fps/8, see tick structure above). Ported from decompile + fitted to recordings: * MSVC rand() LCG; global phase machine from FUN_00403ef0 (intro phase 0 for 120 ticks = animation time 0.1/tick to 12, then normal phase; rare tumble phases 3/4 with 600/500 tick timers; camera mode change p=1/1024 per tick via rand%25 table: Closeup 2/25, Tracking 6/25, Trailing 3/25, SlowTrack 3/25, Still 4/25, Stationary 7/25). * Camera follow constants (FUN_004037f0): Closeup k=1/15 (+0.2 y), Tracking k=1/128 (+0.2), SlowTrack k=1/256 (+1.0); keeps distance 60/150/150 from target; LookAt target each tick; floor clamp. * Ornament AI: speed rule (35%: x0.988, else +0.17 while <150), steering dir=normalize(dir + (1/15)*unit(target-pos)) (max turn 3.81 deg/tick, matches recorded 3.8), re-target when <150, flocking copies another entity's target with p=(1023-768)/1024 per tick (Flocking=0x100), collisions <25 units => spin + speed x0.75, special-move spins. Validation vs 19 recorded ornaments over 500 ticks: mean step 19.7 (recorded 17.4), mean turn 1.13 deg (recorded 1.02), max turn 3.79 (recorded 3.8 excluding jumps). * Intro camera: Hermite spline through keys (t=0,2,4,12), interior tangents = chord slope over neighbours, end tangents half the end chord slope. RMS 39 units vs recorded path A (max 274 at one noisy sample). Path chosen randomly (A/B). * Train: deterministic recorded table for first 2176 ticks (public/train_table.json, from run-train 575 s capture), circling the tree area afterwards; fallback steering weight 0.0375, speed 8, targets in +-520 box around tree. (Capture tool: proxy with D3DLOG_LITE= env var.) NOT yet ported: light strings/colored light FX, smoke puffs, snow flakes, shock sparks, exact state 0/5/7/8/9 handlers (follow-buddy, chase, collision tumble details), terrain-following floor (flat y=0 used), music/MIDI, AVI intro. ## Effects ported into sim.js / viewer (latest) * Snow (FUN_00403c60/004047a0/004048c0): pool 150 (light) / 300 (heavy), spawn each tick if (rand&0x3ff)>500 (light: 2 flakes) or always (heavy: 12), centred 3000 units ahead of the camera, +-4096 in x/z, +1024..2047 up; velocity ((rand%10-5)*0.05 + wind, rand%5-25+vyArg, ...), wind re-rolled with p~1.2%/tick (|wind| 10, heavy 30..45), life 2500 ticks or until below terrain; 4 flake textures FLK10/11/15/16, decal size SnowSize=25. * Smoke/spark puffs (FUN_004019a0/00401c90): pool 50, 16-frame animation SMK1..16 (one frame per tick); emitted every tick by the kind-0 entity (CANDCANE trail), by the train chimney (30 ahead, 13 up, vy 3, p~12%/tick) and on collisions. * Light strings: two GBREAD ornaments (kind 3) each carry 10 wire segments (LIGHTSL*.x, 76-unit links, colours random B/R/Y/P) that chain behind the carrier; glow decals GLOWB/P/R/Y added in the viewer; blink pattern is a placeholder (3 of 4 lit, chasing). * Terrain: height field built from LAND.x (scale 222, y offset -25) used for ornament floor (+30), camera floor (+2), snow ground hit. * Roster fixed from recordings: [0] CANDCANE (kind 0), [1],[2] GBREAD (kind 3), rest random ornaments. Still approximate/missing: exact blink matrix (DAT_004260e0), coloured point-light FX, entity states 0/5/7/8/9 details, tumble phase visuals, SHOCK ring model, music, intro AVI, sky/stars (star field in original frames). ## Comparison vs original (latest) * BUG FIXED: z-mirrored triangles had flipped winding so directional/spot lights contributed ~nothing; models now orient winding to the file normals (main.js model()). Earlier lighting fits compensated for that; refit with working lights: ambient 0.8, directional 0, tree emissive 1.0 (opacity 0.35), gift colour x0.6 (mean abs error vs original 21.7 over intro + later frames; was 24-26 with the bug). Remaining brightness mismatch: ground near tree orig ~154 vs replay ~126; far ground orig ~95 vs ~116 (a spotlight cone fit did worse). * After the intro the original camera TRACKS entity 0 (mode 2, k=1/128, D4=150): fixed in sim.js (was Stationary). * Live-sim frames now resemble the original's: terrain horizon, black sky, snow flakes, expanding smoke trail behind the candy cane, blue light strings. Random events differ per run so only look/statistics can be compared, not exact frames. ## Music (added) Original: FUN_00405af0 picks rand()%20 of the 20 embedded MIDI resources, plays via MCI sequencer to the end (MM_MCINOTIFY), then repeats. Browser: music/render.mjs renders each MIDI with spessasynth_core + GeneralUser GS (soundfont is NOT shipped), tools ffmpeg -> 80 kbps MP3, loudnorm, 2 s tail + fade (29 MB total, 48.5 min). src/jukebox.js plays them in random order. manifest.json has titles and a `pd` public-domain flag.