Operator report: artifacts bleed from the colour/radar display into the MFDs, present in BT411 too, absent in the original. Almost certainly the same "intermittent plane-write artifact" that survived seven earlier hypotheses. The chain, every link read from the code: 1. The colour head is SIX BITS (L4GAUGE.CFG:4395, sec mask 0x003F), and the MFDs are bits 8-15 of the same 16-bit words -- one plane-packed framebuffer, which is why a colour-head defect surfaces as MFD garbage. 2. BuildSecondaryTranslation (L4VB16.CPP:5419) fills exactly 64 entries of int translationTable[256]; entries 64..255 are never initialised. 3. DrawPixelMap8 (L4VB16.CPP:2708) indexes that table with an 8-BIT source pixel -- no clamp. 4. DrawPoint/Replace (L4VB16.CPP:637) ORs the colour in UNMASKED; the writer assumes the caller already confined it to the port's bits. 5. The sec port's own art supplies out-of-range indices, and they are SENTINELS not colour data: AVACRIT.PCC is 94% <= 63 with 6% at exactly 231; BTSEC1.PCX is 99.8% <= 63 with 0.2% at 254. Transparent they are skipped; OPAQUE they index the uninitialised tail. Why the original is clean: translationTable lives in a heap-allocated port, and on the pod that allocation was almost certainly zeroed -- garbage reads as colour 0, black, invisible. Our heap has different history. That also explains the intermittency that defeated the earlier hypotheses: it depends on heap contents, not on drawing logic. Narrowed to three opaque call sites (BTL4GAU2.CPP:916/1159, BTL4GAUG.CPP:1953). The fix belongs in our tree via the source410-shadows- CODE rule -- zero the table's tail after BuildSecondaryTranslation -- which is worth doing regardless of which site is guilty, since it turns a heap-dependent artifact into a black pixel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2044 lines
116 KiB
Markdown
2044 lines
116 KiB
Markdown
# The render roadmap — from headless to visible (researched 2026-07-24)
|
|
|
|
Four-dossier workflow synthesis (card protocol / video-TU delta / emulator
|
|
side / gauge path). The headline: **the render seam is far closer than
|
|
expected** — the engine half of BOTH render paths already compiles and links
|
|
into our btl4opt.exe from the CODE originals, and the emulator's board-side
|
|
device is EXE-agnostic.
|
|
|
|
## The two independent render paths (either can run without the other)
|
|
|
|
1. **The 3D main view (division card)**: the game speaks dPL — a
|
|
RETAINED-mode scene graph API (never pixels, never transformed polys) —
|
|
through the statically linked libDPL, which frames every call into
|
|
vr_action messages on the INMOS C012 link adapter at port 0x150
|
|
(FIFO fast path: body as REP OUTSW words to 0x154, trailing length word
|
|
to 0x151 commits). Gate: `DPLARG` env (set by SETENV.BAT when
|
|
L4VIDEO != OFF); NULL DPLARG = the headless shape we run today
|
|
(`MakeVideoRenderer()` returns NULL, tolerated).
|
|
- Per-frame submission: **engine code** — `DPLRenderer::
|
|
ExecuteImplementation` (CODE/RP/MUNGA_L4/L4VIDEO.CPP:5065) walks the
|
|
dplRenderableSocket; renderables (L4VIDRND.CPP, ~30 classes) set DCS
|
|
matrices; `dpl_FlushDCSArticulations` = wire action 0x1f (batched
|
|
poses, absolute 3x3+T or [sin,cos] joint records); `vr_draw_scene`
|
|
(9) kicks the frame; `velocirender_sync` (0x2d, exe @0x48D220) is the
|
|
ack-less bulk-op fence.
|
|
- **Our build ALREADY COMPILES this whole engine layer** (mungal4.lib:
|
|
l4video/l4vidrnd/l4dplmem + prebuilt 1995 STARTDPL.obj + LIBDPL.LIB;
|
|
see build410 obj logs). The missing piece is GAME code: **btl4vid**
|
|
(BTL4VideoRenderer — MakeEntityRenderables/MakeMechRenderables/
|
|
effects/wreck swap/material substitution; BT411 donor 3039/848 lines
|
|
with per-method @ADDR evidence, binary extent @4cc40c..@4d2bbc; our
|
|
stub Fails at LoadMissionImplementation).
|
|
2. **The gauge path (cockpit instruments)**: completely separate hardware —
|
|
the PC's own SVGA (VESA 0x111, 640x480x16, L4SVGA16.ASM banked blits),
|
|
split across VGA heads by the VDB board (port 0x300, palette groups; the
|
|
octopus-cable 5-mono-MFDs + color-radar decode). Gate: `L4GAUGE` env.
|
|
The plasma readout is a gauge-renderer graphics port (PlasmaDisplay on
|
|
COM2), not a separate renderer.
|
|
- Engine half (GaugeRenderer/L4GaugeRenderer/the 5171-line L4GAUGE
|
|
widget interpreter/GAUGMAP/L4VB16) already compiles + links in
|
|
build410; one documented deviation (interpreter table 46864 -> 262144
|
|
for BT's larger CFG).
|
|
- Missing: the 6 BT gauge TUs — **btl4gaug (130 fn) / btl4gau2 (77) /
|
|
btl4gau3 (46) / btl4rdr (36) / btl4grnd (13, ours is a 4-fn stub) /
|
|
btl4galm** — ~302 functions/~43KB, binary extent 0x4c19fc-0x4cc40c,
|
|
the largest function-count block left in btl4.lib. BT411 donors are
|
|
COMPLETE (semantic .cpp/.hpp with @ADDR evidence, ~5900 donor lines);
|
|
the work is RE-HOSTING onto 1995 engine APIs, not fresh decompilation.
|
|
Class inventory per TU in the dossier (widget primitives / composite
|
|
clusters / MP-HUD / radar MapDisplay / the renderer / alarm manager).
|
|
|
|
## The emulator side is READY and EXE-agnostic
|
|
|
|
The DOSBox-X fork's VPX device (emulator/vpx-device/vpxlog.cpp, ports
|
|
0x150-0x161 + VDB 0x300-0x31A) implements the BOARD side purely at the wire
|
|
level: blind firmware absorb, iserver handshake (VPX_RESPOND=1), generic
|
|
acks, the 0x2d sync echo, draw_scene frame-acks with a REAL raycast reticle
|
|
pick against its own decoded scene, and the GL renderer (VPX_RENDER=1) that
|
|
draws the main view + cockpit windows. It never reads game memory and keys
|
|
only on wire contents — **our reconstructed exe drives it unmodified once it
|
|
emits the same traffic**. The AWE32 pair is mandatory for any timed run
|
|
(SOS FAST clock); gauges also render live on the emulated S3 with the
|
|
shipped exe (GAUGE-NOTES.md: HEAPSIZE=15000000 + granularityInKB=64).
|
|
|
|
## The plan (order of value)
|
|
|
|
1. **Gauges-only first** (decisively cheaper): set `L4GAUGE=640x480x16` (+
|
|
HEAPSIZE), reconstruct the 6 gauge TUs from the BT411 donors. Built-in
|
|
oracle: the shipped exe renders reference output from the SAME GAUGE
|
|
content — per-head diffable. Live instruments need the attribute
|
|
sources, which the 5.3.x waves have largely built (weapon states, heat,
|
|
power, damage, radar).
|
|
2. **Then btl4vid** (the 3D world renderer) + DPLARG on: the engine
|
|
DPLRenderer + libDPL do the wire; btl4vid builds the renderables per
|
|
entity; verify against the VPX device's GL window and the dpl3-revive
|
|
bridge.
|
|
3. The review/playback pair (btl4pb + the BTL4.CPP review branch) closes
|
|
the last absent TUs.
|
|
|
|
Message-id / protocol quick refs: vr_create=1 (host-assigned handles),
|
|
vr_flush=3 (node structs; VIEW proves 832x512), geometry 0x17/0x19/0x1a
|
|
(texel word [pad,B,G,R]), artics 0x1f, draw 9, sync 0x2d, fire 0x23,
|
|
reticle 0x26. DPLARG reference value in emulator/baseline.conf:21.
|
|
|
|
================================================================================
|
|
2026-07-27 -- THE 3-D PATH RUNS TO MISSION LOAD (measured, not estimated)
|
|
================================================================================
|
|
Our reconstruction, run against the emulated Division card, gets ALL THE WAY to
|
|
the mission scene load before it stops -- at the one method we stubbed.
|
|
|
|
c:/.../bt_l4/btl4vid.cpp(42): BTL4VideoRenderer::LoadMissionImplementation
|
|
-- btl4vid.cpp not yet reconstructed
|
|
|
|
What that single line proves is working end to end in OUR build:
|
|
* BTL4Application::MakeVideoRenderer builds a BTL4VideoRenderer
|
|
* the BTL4VideoRenderer ctor chains the engine's DPLRenderer
|
|
* the board boots: transputer + i860 firmware upload, ~858K wire
|
|
transactions logged by the VPX device HLE
|
|
* Renderer::LoadMission runs (RENDERER.CPP:273) and calls the hook
|
|
|
|
So btl4vid is NOT a from-scratch climb. The engine half was always linked;
|
|
what is missing is the game's mission-load hook and the renderables it builds.
|
|
|
|
HOW TO REPRODUCE (this is the rig, and it is cheap):
|
|
emulator/vidtest.conf -- baseline.conf with the mount made absolute and
|
|
BTL4REC.EXE (our build, staged beside the shipped BTL4OPT.EXE which is NOT
|
|
touched) instead of the shipped exe. Host env before launching dosbox-x:
|
|
|
|
VPX_RESPOND=1 VPX_RENDER=1 VPX_NOMAIN=1 VPXLOG=<file> VPX_DUMPDIR=<dir>
|
|
|
|
VPX_RESPOND alone is NOT enough -- with only that, the board boot dies at
|
|
"Protocol error : length 65535 too big / rcv_protocol fail during iserver
|
|
handling", which is the iserver handshake reading a floating bus.
|
|
VPX_NOMAIN=1 keeps it from opening windows; add VPX_EXPLODE=1 only when you
|
|
want the 7-display pentapus. The full documented pod launch is
|
|
render-bridge/launch_pod.ps1 (LAUNCH.md) when the GL bridge is wanted.
|
|
|
|
THE CONTRACT for the next brick (authentic, CODE/RP/MUNGA/RENDERER.CPP:263):
|
|
Renderer::LoadMission verifies InactiveRendererStatus, sets
|
|
LoadingRendererStatus, stamps nextRenderTime, calls
|
|
GetRendererManager()->StartRenderer(this), THEN LoadMissionImplementation.
|
|
The base implementation Fails ("should never reach here") -- every concrete
|
|
renderer must override it.
|
|
|
|
DONOR: BT411 game/reconstructed/btl4vid.cpp, 3188 lines / 27 methods, carrying
|
|
the renderable family (BTReticleRenderable, BTTranslocationRenderable, the
|
|
pending-wrecks map). NOTE it does not contain a LoadMissionImplementation by
|
|
that name -- the WinTesla port restructured the hook -- so take the CONTRACT
|
|
from the 1995 engine above and the CONTENT from the donor's renderables.
|
|
|
|
CORRECTION to the note earlier today: a transient 3-D cockpit frame captured
|
|
during a gauge-rig run was NOT evidence of the dPL path emitting anything.
|
|
MakeVideoRenderer returns NULL unless DPLARG is set (BTL4APP.CPP:152), and no
|
|
gauge conf sets it -- so no video renderer exists in those runs at all. That
|
|
frame was almost certainly attract/loading art out of BTL4.RES. The evidence
|
|
above is the real thing, and it came from setting DPLARG deliberately.
|
|
|
|
--------------------------------------------------------------------------------
|
|
TWO RUNGS CLIMBED -- the 3-D pipeline now runs end to end to the geometry load
|
|
--------------------------------------------------------------------------------
|
|
RUNG 1: BTL4VideoRenderer::LoadMissionImplementation, Fail -> bring-up no-op.
|
|
A no-op is a LEGAL body, not a cheat: the engine's own
|
|
VideoRenderer::LoadMissionImplementation (CODE/RP/MUNGA/VIDREND.CPP:259) is a
|
|
bare Tell, and our working GaugeRenderer (GAUGREND.CPP:3275) ships the same.
|
|
The renderer comes up and runs its frame loop with an EMPTY scene, which is
|
|
what we wanted to measure before writing content.
|
|
|
|
RUNG 2: Mech::EyepointRotation PUBLISHED. With the renderer live the engine
|
|
immediately Failed at
|
|
|
|
l4video.cpp(5004): DPLRenderer::SetupCull jointed mover had no
|
|
EyepointRotation attribute
|
|
|
|
SetupCull (CODE/RP/MUNGA_L4/L4VIDEO.CPP:4990) fetches "EyepointRotation" BY
|
|
NAME off the viewpoint entity and, for a JointedMover, composes it with the
|
|
"siteeyepoint" segment to build worldToEyeMatrix. Our mech already HAD
|
|
EulerAngles eyepointRotation with a getter (MECH.HPP) -- only the attribute
|
|
publication was missing. One enum id + one table row. (The siteeyepoint
|
|
check passed first time, so that segment was already streaming correctly.)
|
|
|
|
WHERE IT STOPS NOW, and why that is not a defect:
|
|
|
|
L4VIDEO.cpp couldn't load object buttee.bgf (x123, also dg100/mslr)
|
|
NULL instance
|
|
|
|
ZERO Fails, zero exceptions -- the run reaches the full mech build (all 7
|
|
weapons), constructs the renderer, boots the board (~907K VPX wire
|
|
transactions), loads the mission, runs the per-frame cull and gets all the way
|
|
into geometry loading. The models ARE present (VIDEO/GEO/BUTTEE.BGF etc, 879
|
|
BGFs) and BTDPL.INI points at them (objectpath=.ideo\geo), but loading a
|
|
.BGF goes through the board to the EXTERNAL GL render bridge that the VPX HLE
|
|
tees over VPX_FIFOSOCK -- and that bridge was not running. "NULL instance"
|
|
comes from the prebuilt LIBDPL.LIB, i.e. the library refusing to instance an
|
|
object that never loaded.
|
|
|
|
NEXT RUNG: run under the documented full rig (render-bridge/launch_pod.ps1,
|
|
see LAUNCH.md) so the bridge is up and geometry actually loads. Only then is
|
|
it worth writing real MakeEntityRenderables content -- until the bridge is in
|
|
the loop there is nothing to see even if the scene graph is correct.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE FULL POD RIG: geometry loads, and the ladder is now the AUDIO WATCHER SET
|
|
--------------------------------------------------------------------------------
|
|
Run under the documented rig (render-bridge/launch_pod.ps1 with the GL bridge
|
|
up) the geometry complaint COUNT WENT TO ZERO -- .BGF loading was never a
|
|
reconstruction problem, only a missing bridge, exactly as predicted.
|
|
|
|
emulator/render-bridge/gauge_arena_rec.conf = gauge_arena_sound.conf with
|
|
the exe swapped to BTL4REC.EXE (our build, staged into ALPHA_1/REL410/BT
|
|
beside the shipped BTL4OPT.EXE, which is NOT touched).
|
|
Launch: .\launch_pod.ps1 -Conf ...\gauge_arena_rec.conf
|
|
|
|
What blocks now is a SERIES of authored AttributeWatchers. BTL4.RES binds
|
|
watchers BY NAME and the engine Fails outright on any that does not resolve
|
|
(WATCHER.CPP:141) -- these are the sound triggers, so every one of them is a
|
|
name our subsystems must publish.
|
|
|
|
RUNGS CLIMBED THIS PASS (each one run-verified, ~6 min per cycle):
|
|
UnstablePercentage Mech, staged member (no instability model yet)
|
|
SpeedEffect Myomers -- member existed, table published
|
|
AnimationState Mech -- member existed (bound 307x in the RES!)
|
|
CollisionState/Normal Mech -- state existed, normal staged
|
|
ReduceButton Mech, staged
|
|
SpeedOfTorsoHorizontal Torso -- all six names mapped to real members
|
|
|
|
THE METHOD THAT MAKES THIS CHEAP (use it instead of one rung per run):
|
|
The shipped binary's string pool carries each class's attribute names
|
|
CONTIGUOUSLY, in ID ORDER, right after the class name and its message names.
|
|
So: strings -a BTL4OPT.EXE | grep -n -A14 '^Torso$' gives the authentic
|
|
table for that class, and in every case so far our member declarations sit in
|
|
the SAME ORDER -- which both confirms the reconstruction's layout and lets the
|
|
ids be pinned rather than guessed. Cross-reference against the resource with
|
|
|
|
comm -12 <(strings BTL4.RES|grep -x '[A-Z][A-Za-z0-9]\{3,\}'|sort -u) <(strings BTL4OPT.EXE|...|sort -u)
|
|
|
|
which returns 59 candidate names; the attribute-like ones are the work list.
|
|
|
|
STILL TO PUBLISH (from that intersection, owner in brackets where known):
|
|
MotionState [Torso? -- sits between StickPosition and
|
|
TorsoHorizontalEnabled in the pool, but that region MIXES attribute
|
|
names with resource-field names, so confirm before publishing]
|
|
LocalVelocity, LocalAcceleration [Mover/Entity]
|
|
AmmoState [AmmoBin] CondenserState [Condenser]
|
|
ReservoirState [Reservoir] GeneratorState [Generator]
|
|
SimulationState [Subsystem] DisplayMode [ControlsMapper]
|
|
LookForward/Left/Right/Down/Behind [MechControlsMapper]
|
|
LaserOn, ReportLeak, FireCountdownStarted, TargetRangeExponent,
|
|
ConfigureActivePress, StickPosition
|
|
|
|
A THIRD MISWIRED SharedData FOUND ON THE WAY: Myomers carried
|
|
Subsystem::MessageHandlers + Subsystem::AttributeIndex where it derives from
|
|
PoweredSubsystem -- the same defect as MissileLauncher (5.3.33), dropping
|
|
every powered attribute AND the powered message handlers. Torso is wired to
|
|
Subsystem's too, but there the chain (PowerWatcher -> HeatWatcher ->
|
|
MechSubsystem) genuinely publishes nothing, so only its AttributeIndex moved.
|
|
WORTH A SWEEP: check every subsystem's DefaultData against its actual parent.
|
|
|
|
TYPES ARE PROVISIONAL on the staged members. AttributeWatcherOf<T> reads
|
|
*(T*)attributePointer (WATCHER.HPP:288) and the instantiation comes from the
|
|
resource, which the string pool does not reveal. Nothing drives these yet so
|
|
no watcher can fire -- settle the types WITH the models that write them.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE WATCHER LIST IS NOW FULLY KNOWN -- stop discovering it one run at a time
|
|
--------------------------------------------------------------------------------
|
|
BTL4.RES stores each watcher as a (SUBSYSTEM, ATTRIBUTE) string pair, so the
|
|
whole list can be read straight out of the resource instead of one 6-minute
|
|
run per name:
|
|
|
|
for each candidate name, find its NUL-terminated occurrences in BTL4.RES
|
|
and take the printable string immediately before it -- that is the owning
|
|
subsystem. Candidates = strings present in BOTH BTL4.RES and BTL4OPT.EXE
|
|
matching ^[A-Z][A-Za-z0-9]{3,}$ (59 of them).
|
|
|
|
Normalised across instance names (ERMLaser_1/2/3 -> the weapon class,
|
|
AmmoBinSRM6_1 -> AmmoBin, Condenser1..6 -> Condenser, GeneratorA..D ->
|
|
Generator), the COMPLETE set the engine will demand is:
|
|
|
|
Entity AnimationState(306) LocalVelocity(324) SimulationState(74)
|
|
CollisionSpeed(72) CollisionState(54) FootStep(36)
|
|
IncomingLock(36) UnstablePercentage(36) LocalAcceleration(18)
|
|
DistanceToMissile(18) ReduceButton(18) CollisionNormal(18)
|
|
weapons WeaponState PercentDone LaserOn ReportLeak
|
|
ConfigureActivePress SimulationState
|
|
AmmoBin AmmoState FireCountdownStarted SimulationState
|
|
Condenser CondenserState ReportLeak
|
|
Generator GeneratorOn GeneratorState ReportLeak
|
|
Reservoir ReservoirState
|
|
Myomers SpeedEffect ConfigureActivePress ReportLeak SimulationState
|
|
Avionics(Sensor) ConfigureActivePress ReportLeak SimulationState
|
|
ControlsMapper DisplayMode Look{Forward,Left,Right,Down,Behind}
|
|
TargetRangeExponent
|
|
Torso SpeedOfTorsoHorizontal MotionState
|
|
HeatSink CurrentTemperature
|
|
|
|
DONE so far (6 rungs, each run-verified): UnstablePercentage, SpeedEffect,
|
|
AnimationState, CollisionState/Normal, ReduceButton, SpeedOfTorsoHorizontal
|
|
(+ the whole Torso table), MotionState. LocalVelocity / LocalAcceleration
|
|
need nothing -- the ENGINE's Mover already publishes them (MOVER.CPP:104).
|
|
|
|
LEAF-SAFE APPENDS (do these next; each goes at the END of a class that
|
|
nothing chains off, so no id moves): ReservoirState, CondenserState,
|
|
GeneratorState (maps to the EXISTING stateAlarm), AmmoState +
|
|
FireCountdownStarted (AmmoBin has no table at all yet), LaserOn (Emitter),
|
|
TargetRangeExponent (ControlsMapper).
|
|
|
|
--------------------------------------------------------------------------------
|
|
!! ReportLeak AND ConfigureActivePress ARE NOT LEAF-SAFE -- and they reveal
|
|
the authentic attribute layout. DO NOT bulk-edit these without deciding.
|
|
--------------------------------------------------------------------------------
|
|
Both belong to BASE classes in the pinned range. The shipped string pool
|
|
gives each class's attribute names contiguously in id order:
|
|
|
|
MechSubsystem ConfigureActivePress (1)
|
|
HeatableSubsystem CurrentTemperature DegradationTemperature
|
|
FailureTemperature (3)
|
|
HeatSink NormalizedPressure DegradationPressure
|
|
CoolantCapacity CoolantMass
|
|
CoolantMassLeakRate ReportLeak (6)
|
|
PoweredSubsystem InputVoltage AuxScreenNumber AuxScreenPlacement
|
|
AuxScreenLabel EngScreenLabel (5)
|
|
|
|
Count them from Subsystem::NextAttributeID = 2: 2 | 3..5 | 6..0x0B |
|
|
0x0C..0x10 -> NextAttributeID = 0x11 -> MechWeapon needs EXACTLY ONE pad
|
|
to land its real ids on the binary-pinned 0x12. Our tree needs THREE pads,
|
|
because our tables differ: we publish HeatLoad (authentic does not),
|
|
CoolantAvailable (authentic does not), and a different PoweredSubsystem five
|
|
(OutputVoltage/RatedVoltage/VoltageState/ConnectMode instead of the four
|
|
AuxScreen* names). The one-pad arithmetic landing exactly on 0x12 is strong
|
|
evidence the four rows above ARE the original layout.
|
|
|
|
Reconciling to it is a real improvement -- it would replace three guessed
|
|
pads with the authentic table -- but it moves ids underneath the gauge
|
|
cockpit that is currently verified at 98.9% pixel-identical, so it wants a
|
|
deliberate session with the A/B rig open, not a bulk edit at the end of a
|
|
long one. Neither HeatLoad nor CoolantAvailable is bound by any gauge (0
|
|
hits in L4GAUGE.CFG), so the drop side looks cheap; the risk is the id shift,
|
|
not the names.
|
|
|
|
--------------------------------------------------------------------------------
|
|
A TRAP IN THE WATCHER WORK: publishing a name can CRASH the pod
|
|
--------------------------------------------------------------------------------
|
|
Publishing Reservoir/ReservoirState crashes the pod on the DOS extender
|
|
("Reference to a page you don't own" -> PF), and BISECTING proved it is that
|
|
one change: revert it and the run returns to a clean Fail; re-apply it alone
|
|
and the crash returns. Everything else in the same batch (Condenser,
|
|
Generator, Emitter, ControlsMapper, Torso, Myomers) is innocent and is IN.
|
|
|
|
WHY IT MATTERS BEYOND ONE ATTRIBUTE: AttributeWatcherOf<T> does
|
|
|
|
currentValue = *(T*)attributePointer; (WATCHER.HPP:306)
|
|
|
|
AT CONSTRUCTION. So a published name is READ the moment its watcher is
|
|
built -- "nothing drives the value yet, so the type cannot matter" (which is
|
|
what the earlier staging note claimed) is WRONG. A wrong-typed target is a
|
|
live crash, not a deferred cosmetic issue.
|
|
|
|
Things ruled out by measurement, so nobody repeats them:
|
|
* NOT the AlarmIndicator-vs-int type. Publishing a plain int
|
|
reservoirState instead got FURTHER (log 232 -> 749 bytes) but still
|
|
crashed, so the object copy is not the whole story.
|
|
* NOT static-init order. reservr.obj sorts LAST in the bt lib (build410.sh
|
|
puts TUs missing from the 1995 order list at the end), so
|
|
HeatSink::AttributeIndex is constructed before Reservoir's.
|
|
* NOT an id gap. ReservoirState sits at HeatSink::NextAttributeID with the
|
|
parent chain contiguous behind it.
|
|
* NOT visible on the gauge rig. The same binary runs clean under
|
|
vis_rec.conf -- only the pod/arena context faults, so reproduce it there.
|
|
|
|
The crash itself: 0044B4AD, `mov eax,[edx+0x18]` then `call [eax+4]`, with
|
|
EAX = 0x15 and the fault at 0x19. A small integer is being called through as
|
|
an object -- an index slot holding an ID where a pointer belongs. That is
|
|
the AttributeIndexSet::Build uninitialised-slot signature, so the next step
|
|
is to dump Reservoir's built index set and compare it against a class whose
|
|
publication works (Condenser is the control: same base, plain int, no crash).
|
|
|
|
SOLVED. The linker map named the faulting function outright:
|
|
|
|
tlink32 -m writes build410/btl4opt.map; the crash address minus the CODE
|
|
base (0x410000) is the map offset, and "Publics by Value" gives the
|
|
enclosing symbol. 0x3B4AD landed inside
|
|
AudioStateWatcher::AudioStateWatcher(PlugStream*,Entity*) +0x2D.
|
|
|
|
AudioStateWatcher is AudioWatcherOf<StateIndicator> and its ctor runs
|
|
|
|
Cast_Object(StateIndicator*, attributePointer)->AddAudioWatcher(this)
|
|
(AUDWTHR.CPP:891)
|
|
|
|
immediately. So every authored *State name MUST be published as a
|
|
StateIndicator (AlarmIndicator counts -- it derives from one). Point it at a
|
|
plain int and that member call goes through garbage and the pod dies on the
|
|
extender. That is why BOTH earlier attempts failed: the AlarmIndicator try
|
|
was right in type but was tested while other bugs were still in the batch,
|
|
and the "plain int" try was wrong in type and merely crashed further along.
|
|
|
|
Fixed by publishing state objects: Reservoir/ReservoirState -> reservoirAlarm,
|
|
Generator/GeneratorState -> stateAlarm, and NEW StateIndicator members for
|
|
Condenser/CondenserState and Torso/MotionState. Note StateIndicator has no
|
|
Initialize() -- it takes its count in the ctor, and the default ctor is
|
|
enough here because the watcher only needs the object to exist (the socket it
|
|
attaches to is a member). Verified on the pod: no crash, ladder advanced to
|
|
ReportLeak.
|
|
|
|
THE REUSABLE TECHNIQUE: when the pod dies with an extender fault, take the
|
|
address out of the dump, subtract 0x410000, and look it up in
|
|
btl4opt.map. It names the exact engine function -- which, for this family of
|
|
work, names the watcher class and therefore the required member type.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE WATCHER LADDER IS DONE -- and the next brick is named by the engine itself
|
|
--------------------------------------------------------------------------------
|
|
Every authored AttributeWatcher now resolves. The pod run reaches the mission,
|
|
DRAWS THE FULL COCKPIT (sensor cluster, myomers, cooling, the weapon panels,
|
|
kills/deaths) with the board booted and audio running, and exits GAME-RC=0
|
|
without a Fail. What it does NOT do is build a 3-D scene, and the engine says
|
|
why in one line:
|
|
|
|
Entity 1:1 class3035 couldn't figure out how to MakeEntityRenderables
|
|
L4VIDEO.cpp couldn't load object sky.bgf (then the whole arena:
|
|
aw02/aw03/aw04/afloor/bcor1/bdet1/bdet2/bpip1 ...)
|
|
NULL instance
|
|
|
|
VideoRenderer::MakeEntityRenderables (CODE/RP/MUNGA/VIDREND.CPP:231) is the
|
|
BOTTOM of a virtual chain -- its own comment says "when a higher level of this
|
|
virtual can't figure out how to make something it calls down to the level
|
|
below it. If we reach here then nobody could figure out what to do." Our
|
|
BTL4VideoRenderer does not override it, so every entity falls through to the
|
|
complaint, no scene graph is built, and the arena geometry that would hang off
|
|
it never loads. class3035 = 0xBDB.
|
|
|
|
CORRECTION to the earlier note in this file: the first full-rig run was
|
|
recorded as "geometry complaint count went to ZERO -- .BGF loading was never a
|
|
reconstruction problem, only a missing bridge". That reading was wrong. The
|
|
count was zero because the run Failed at an attribute watcher long BEFORE
|
|
reaching geometry. Now that the watchers all resolve, the loads are attempted
|
|
and they fail -- because nothing built the renderables to hang them on, not
|
|
because of the bridge.
|
|
|
|
SO THE NEXT BRICK IS THE REAL btl4vid BODY, and its shape is pinned by the
|
|
surviving sibling header CODE/RP/RP_L4/RPL4VID.HPP:
|
|
|
|
void MakeEntityRenderables(Entity*, ResourceDescription *model_resource,
|
|
ViewFrom type); <- override this
|
|
dpl_DCS* ReadSKLFile(Entity*, ResourceDescription*, ViewFrom,
|
|
char skeleton_type);
|
|
void RecurseSKLFile(Entity*, dpl_DCS *parentDCS, NotationFile *skeleton,
|
|
const char *page_name, int recursion_depth, ViewFrom,
|
|
int *joint_counter, dpl_LOAD_MODE cache_mode);
|
|
char SetupMaterialSubstitutionList(Entity*, ViewFrom);
|
|
void TearDownMaterialSubstitutionList();
|
|
|
|
i.e. walk the mission's entities, read each one's .SKL skeleton notation pages
|
|
(VIDEO/*.SKL are present in the mount), recurse them into a dpl_DCS hierarchy,
|
|
and load the .BGF geometry per node. Content for the BT-specific renderables
|
|
comes from the BT411 donor (game/reconstructed/btl4vid.cpp, 3188 lines:
|
|
BTReticleRenderable, BTTranslocationRenderable, the pending-wrecks map).
|
|
|
|
--------------------------------------------------------------------------------
|
|
FIRST RENDERABLE ANSWER: BTPlayer -- and how to read a NON-crashing pod run
|
|
--------------------------------------------------------------------------------
|
|
BTL4VideoRenderer now overrides MakeEntityRenderables. First answer:
|
|
class 3035 = BTPlayerClassID (VDATA.HPP: the BT block starts at
|
|
BTL4ApplicationClassID = 3000, so counting down the enum gives 3035) carries
|
|
NO GRAPHICS -- exactly what the engine already does for its own
|
|
PlayerClassID with an empty case. Everything else still chains to
|
|
DPLRenderer, so this override can only ADD answers, never remove one the
|
|
engine already gives.
|
|
|
|
Result on the pod: the "couldn't figure out how to MakeEntityRenderables"
|
|
complaint is GONE, and the run no longer exits -- it keeps running. But it
|
|
renders nothing: the wire fifodump grows at ~24 bytes/sec (1.4KB total),
|
|
which is keep-alive traffic, not a frame stream. Expected -- the mech and
|
|
the arena still have no renderables, only the player has been answered.
|
|
|
|
A PRACTICAL PROBLEM TO SOLVE FIRST NEXT TIME: a pod run that does NOT crash
|
|
produces NO readable log. The conf redirects the game's stdout
|
|
(> OUTREC.TXT) and DOS buffers it, so the file stays 0 bytes until the
|
|
process exits normally; every log this session that was readable came from a
|
|
run that CRASHED, because the fault handler flushed. Killing the pod loses
|
|
the buffer entirely.
|
|
|
|
Two ways out, both already in the toolbox:
|
|
* `echo GAME-RC=%errorlevel% >> RC.TXT` after the game line -- a separate
|
|
command, so it lands even when the game's own buffer is lost. Already
|
|
added to gauge_arena_rec.conf and it is how the clean GAME-RC=0 exit was
|
|
confirmed.
|
|
* the com3/serial3=file trick from the emulator notes -- live unbuffered
|
|
game output. Worth wiring into the pod conf before the next renderables
|
|
session, otherwise progress is invisible while things are going WELL.
|
|
|
|
NEXT: the mech (MechClassID 3001) and the arena/terrain entities are what
|
|
actually need renderables -- MakeEntityRenderables -> ReadSKLFile /
|
|
RecurseSKLFile over the .SKL skeleton pages (VIDEO/*.SKL are in the mount),
|
|
building a dpl_DCS hierarchy and loading the .BGF geometry per node.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE NO-OP WAS THE BUG: an override REPLACES the base, it does not extend it
|
|
--------------------------------------------------------------------------------
|
|
DPLRenderer::LoadMissionImplementation (L4VIDEO.CPP:6007) is NOT empty:
|
|
|
|
DPLReadEnvironment(mission); // opens L4DPLCFG (btdpl.ini), walks the
|
|
// compare/branch pages for this
|
|
// location/time and calls
|
|
// dpl_SetObjectFilePath / material /
|
|
// texmap from the objectpath= entries
|
|
LoadNameBitmaps();
|
|
|
|
The "bring-up no-op" installed earlier therefore did not do nothing -- it
|
|
SUPPRESSED the engine's own mission load, so the dPL library never learned
|
|
where the art lives and every dpl_LoadObject returned NULL. That is the whole
|
|
explanation for the 40 failed arena objects and the NULL instance.
|
|
|
|
Justifying the no-op by pointing at VideoRenderer::LoadMissionImplementation
|
|
(a bare Tell) and GaugeRenderer's (the same) was the error: neither is OUR
|
|
base. Check the ACTUAL base before overriding anything in this engine, and
|
|
chain it unless there is a reason not to. (DPLReadEnvironment is private to
|
|
DPLRenderer, so chaining is the only way a game renderer can reach it -- more
|
|
evidence the authentic btl4vid chained too.)
|
|
|
|
BEFORE AFTER
|
|
40x "couldn't load object" 0
|
|
"NULL instance", run ends runs on
|
|
wire ~24 bytes/sec (keep-alive) ~12 KB/s (1.17MB and climbing)
|
|
bridge live at 88fps
|
|
|
|
PROVED IT WAS OURS, NOT THE RIG: the SHIPPED binary was run under the same
|
|
conf on the same rig and loaded every object (0 failures). That A/B is cheap
|
|
now and is the right first move whenever the pod misbehaves.
|
|
|
|
STILL BLACK. The bridge renders nothing yet and its camera sits at the
|
|
default (0,10,0) rather than the mech's eyepoint, so what is going over the
|
|
wire is state plus object loads, not a populated scene. The mech
|
|
(MechClassID 3001) and the arena entities still need MakeEntityRenderables
|
|
bodies -- ReadSKLFile / RecurseSKLFile over the .SKL pages into a dpl_DCS
|
|
hierarchy -- and the view/eyepoint flush wants checking once something is
|
|
actually in the zone.
|
|
|
|
--------------------------------------------------------------------------------
|
|
RIG: fast, observable pod runs (use this from now on)
|
|
--------------------------------------------------------------------------------
|
|
emulator/render-bridge/pod_render_rec.conf + `launch_pod.ps1 -NoSound`
|
|
|
|
* NOSOUND IS FINE ON THE SLOW CLOCK. The old note "--no-sound FREEZES
|
|
bt/rp" is half the story: it is the FAST SOS clock that needs the AWE32.
|
|
setenv.bat args are `r s n p` (RIO, SLOW clock, NOSOUND, plasma+gauges).
|
|
Saves the ~4 minute SoundFont upload AND the ~300MB of awe_*.wav taps
|
|
each run.
|
|
* LIVE UNBUFFERED LOG: serial3=file file:<path> plus `> COM3` instead of
|
|
`> OUTREC.TXT`. DOS char devices are not buffered, so the log is
|
|
readable WHILE the game runs. A plain file redirect stays 0 bytes until
|
|
the process exits normally -- which is why every readable log before this
|
|
came from a run that crashed.
|
|
* pod_render_shp.conf is the same conf running the SHIPPED exe, for the A/B.
|
|
|
|
================================================================================
|
|
THE 3-D WORLD RENDERS. Reconstruction -> emulated Division card -> a picture.
|
|
================================================================================
|
|
emulator/render-bridge/first-3d-frame.png -- the arena from the pod: sky,
|
|
horizon, ground, the arena structures along the skyline with their markings,
|
|
running at ~31fps on the VelociRender bridge.
|
|
|
|
The full chain now works end to end in OUR build: MakeVideoRenderer ->
|
|
BTL4VideoRenderer over DPLRenderer -> board boot (transputer + i860 firmware)
|
|
-> Renderer::LoadMission -> DPLReadEnvironment (art paths) -> 40/40 arena
|
|
objects loaded -> mission launch -> per-frame submission over the wire.
|
|
|
|
WHAT ACTUALLY UNBLOCKED IT, and the mistake worth remembering:
|
|
the run was NOT stalled after InitializePlayerLink. I concluded that from a
|
|
150-second sample and it was simply too short. Application::
|
|
CheckLoadMessageHandler (APP.CPP) reposts itself every second and refuses to
|
|
advance until the MIN-priority event queue drains; with the video renderer in
|
|
the mission that load takes minutes, not seconds. Traced with BT_LAUNCH_LOG:
|
|
|
|
[launch] state=11 minPriorityEmpty=0 <- loading, queue busy
|
|
... 60+ ticks ...
|
|
[launch] state=2 minPriorityEmpty=0
|
|
[launch] state=2 minPriorityEmpty=1 <- drained
|
|
BTL4Application::RunMissionMessageHandler (x2 -- the two messages that
|
|
Turning Plasma Score Display On carry WaitingForLaunch ->
|
|
BTL4Application::RunMissionMessageHandler LaunchingMission ->
|
|
Turning Plasma Score Display On RunningMission)
|
|
[tick] roster live (first Sensor frame), radarPercent=1 voltState=4
|
|
|
|
and that matches the SHIPPED binary's sequence line for line. The renderer
|
|
holds a deliberately blank screen until RunningMission
|
|
(L4VIDEO.CPP:5100, "Simple HACK to hold us on a blank screen till the mission
|
|
is actually started") -- so "black" was never a rendering bug, it was the app
|
|
still loading. WAIT FOR THE LAUNCH MARKERS before diagnosing a black pod.
|
|
|
|
STATE OF THE SCENE: no entity is unbuildable any more (zero "couldn't figure
|
|
out how to MakeEntityRenderables"), zero geometry failures, and the frame is
|
|
static only because the mech is parked with no pilot input. The camera in
|
|
the bridge title is the BRIDGE's own viewer (launch_pod: "arrows in the RENDER
|
|
window tune eye height", FP_UPOFF), not the game's eyepoint -- do not read it
|
|
as the pilot view without checking.
|
|
|
|
NEXT: drive it. With controls fed (vRIO is already running in this rig) the
|
|
mech should move and the scene should animate; then the mech's own model and
|
|
the cockpit-interior renderables are what to verify against the shipped
|
|
binary, which is a cheap A/B now (pod_render_shp.conf).
|
|
|
|
--------------------------------------------------------------------------------
|
|
FRAME RATE: measured properly, and what is actually missing from the scene
|
|
--------------------------------------------------------------------------------
|
|
The pod updates roughly once every several seconds. Measured by capturing a
|
|
head window every 2s and counting changed frames (scratchpad/headrate.py):
|
|
|
|
OURS 5 of 12 samples changed (~5s between updates)
|
|
SHIPPED 1 of 12 samples changed (~24s between updates)
|
|
|
|
So the reconstruction is NOT slower than the shipped binary here -- the
|
|
emulated Division board is simply expensive, and both run the pod at this
|
|
rate. CAVEAT on that comparison: ours was being driven (BT_FORCE_THROTTLE,
|
|
speed 14.4, gauges genuinely changing) while the shipped exe ignores those
|
|
env hooks and sat parked, so it had less to redraw. Treat the two numbers as
|
|
"same order", not as a win.
|
|
|
|
WHAT DID COST US 3x, AND IT WAS MINE: BT_MECH_LOG / BT_LAUNCH_LOG write
|
|
per-frame [sim]/[mppr] lines, and with DEBUG_STREAM=cout redirected to COM3
|
|
every one of them goes out through an emulated serial port. Turning them off
|
|
took the wire from 480 to ~1480 bytes/sec. Keep per-frame logging OFF for
|
|
anything timing-related -- pod_render_quiet.conf is that conf.
|
|
|
|
DO NOT READ WIRE BYTES/SEC AS A FRAME RATE. Shipped pushes ~8900 B/s against
|
|
our ~1480, and the difference is not speed -- it is CONTENT. Shipped is
|
|
submitting the mech; we are not:
|
|
|
|
OURS: L4VIDEO.cpp wrong video resource type for object mad.skl
|
|
SHIPPED: (no such line)
|
|
|
|
The mech's model resource is a SKELETON (mad.skl). The engine's default
|
|
MakeEntityRenderables only accepts Object/Rubble resource types and rejects
|
|
anything else with exactly that message (L4VIDEO.CPP:4265), because handling
|
|
skeletons is the GAME renderer's job -- RPL4VID.HPP's ReadSKLFile /
|
|
RecurseSKLFile. So our arena renders but the MECH IS ABSENT, and with it the
|
|
cockpit interior. That single unimplemented path explains both the missing
|
|
model and the lower wire volume.
|
|
|
|
NEXT BRICK, now precisely scoped: in BTL4VideoRenderer::MakeEntityRenderables
|
|
answer MechClassID (3001) by reading the .SKL notation pages (VIDEO/*.SKL are
|
|
in the mount) into a dpl_DCS hierarchy and hanging the per-node .BGF geometry
|
|
off it -- ReadSKLFile + RecurseSKLFile per the sibling header, content from
|
|
the BT411 donor.
|
|
|
|
================================================================================
|
|
SPEC FOR THE NEXT BRICK: the mech skeleton (.SKL -> dpl_DCS tree)
|
|
================================================================================
|
|
Everything needed to write it is now known. Written down so the next session
|
|
is mechanical rather than exploratory.
|
|
|
|
THE FILE FORMAT (VIDEO/MAD.SKL, a plain NotationFile):
|
|
|
|
[DamageZones] dz_dtorso=0 ... dz_utorso=21 (22 of them)
|
|
[ROOT] JointCount=25 DZoneCount=22
|
|
tranx/trany/tranz pitch/yaw/roll
|
|
joint=jointlocal joint=jointshadow
|
|
[jointlocal] parent=ROOT Type=balltranslate
|
|
tranx.. pitch.. joint=jointhip joint=jointlthigh ...
|
|
[jointhip] parent=jointlocal Type=hingex
|
|
Object=mad_hip.bgf <- the geometry
|
|
dzone=dz_hip <- damage-zone tags
|
|
tranx.. pitch.. joint=jointtorso site=siteedz_hip
|
|
|
|
So each page is one node: a local transform, an OPTIONAL .bgf object, zero or
|
|
more damage-zone tags, and its child joint page names. Type= is the joint
|
|
kind (ball / balltranslate / hingex / hingey) and matters for animation, not
|
|
for the initial build.
|
|
|
|
THE dPL CALLS (CODE/RP/MUNGA_L4/libDPL/dpl/DPL.H):
|
|
|
|
dpl_NewDCS() create a transform node
|
|
dpl_SetDCSMatrix(d, float32 *m) its 4x4 matrix
|
|
dpl_AddDCSToDCS(parent, child) parent it
|
|
dpl_AddDCSToScene(d) root it
|
|
dpl_SetDCSZone(d, zone) assign the zone (MakeNewZone gives one)
|
|
dpl_LoadObject(name, dpl_load_normal)
|
|
dpl_NewInstance() / dpl_SetInstanceObject(i,o) / dpl_AddInstanceToDCS(d,i)
|
|
dpl_FlushDCS(d) push it to the card
|
|
|
|
THE ALGORITHM (matches RPL4VID.HPP's ReadSKLFile + RecurseSKLFile):
|
|
ReadSKLFile: open the .SKL as a NotationFile, make the zone, recurse "ROOT".
|
|
RecurseSKLFile(page, parentDCS):
|
|
1. read tranx/y/z + pitch/yaw/roll -> a matrix -> dpl_NewDCS + SetDCSMatrix
|
|
2. parent it (AddDCSToDCS, or AddDCSToScene at the root)
|
|
3. if Object= -> dpl_LoadObject, dpl_NewInstance, SetInstanceObject,
|
|
AddInstanceToDCS (Entity_Being_Created is already set by the caller so
|
|
the C callback can tag geometry with damage zones -- see L4VIDEO.CPP:4176)
|
|
4. record dcs_array[segment index] so DPLJointToDCSTranslator (L4VIDEO.CPP:
|
|
6326) can map joint -> DCS afterwards
|
|
5. for each joint= child, recurse
|
|
|
|
THE ONE UNKNOWN: the exact dpl_MATRIX layout/convention. No surviving source
|
|
calls dpl_SetDCSMatrix -- the RP game file that did is missing, like BT's.
|
|
Derive it from dpl3-revive/spec/VELOCIRENDER_PROTOCOL.md, which decodes DCS
|
|
bodies as "type 5 (dcs), 4x4 dpl_MATRIX each", and from DPLTYPES.H.
|
|
|
|
SUCCESS CRITERION, and it is exact. The dpl3-revive reference capture of a
|
|
real pod decodes as "Transforms: flush bodies type 5 (dcs) x26" and
|
|
"Placements: type 4 (instance) x22". MAD.SKL declares JointCount=25 (+1 root
|
|
= 26) and DZoneCount=22. So a correct walk of this one file should put 26 DCS
|
|
flushes and 22 instance flushes on the wire -- countable in the fifodump
|
|
without looking at a single pixel.
|
|
|
|
--------------------------------------------------------------------------------
|
|
API INVENTORY for the skeleton walk -- all confirmed, nothing left to discover
|
|
--------------------------------------------------------------------------------
|
|
NotationFile (CODE/RP/MUNGA/NOTATION.HPP) reads every field the .SKL needs:
|
|
GetEntry(page, entry, Scalar*) tranx/trany/tranz, pitch/yaw/roll
|
|
GetEntry(page, entry, const char**) Object= (the .bgf)
|
|
MakeEntryList(page, "joint") the child pages
|
|
MakeEntryList(page, "dzone") the damage-zone tags
|
|
NameList iteration is entry = list->GetFirstEntry(); entry;
|
|
entry = entry->GetNextEntry() -- exactly how DPLReadINIPage walks
|
|
objectpath= at L4VIDEO.CPP:1858, so copy that idiom.
|
|
|
|
dPL side, all in libDPL/dpl/DPL.H:
|
|
dpl_NewDCS / dpl_SetDCSMatrix / dpl_AddDCSToDCS / dpl_AddDCSToScene
|
|
dpl_SetDCSZone / dpl_FlushDCS
|
|
dpl_LoadObject(name, dpl_load_normal) / dpl_NewInstance /
|
|
dpl_SetInstanceObject / dpl_AddInstanceToDCS / dpl_FlushInstance /
|
|
dpl_FlushObject
|
|
|
|
THE MATRIX, as far as the evidence goes. A DCS flush body decodes as
|
|
[remote:4][type_check:4][node:4][matrix:64] -- 16 float32, a 4x4 (confirmed
|
|
by dpl3-revive/patha/analyze_scene.py, which reads rest[4:68] as 16 floats).
|
|
Decoding a real BT capture (patha/bt1.raw.bin) prints a near-identity DCS with
|
|
a single 10.0 term in the LAST ROW, which points at row-major with the
|
|
translation in row 3 -- but the printed rows look shifted by one word against
|
|
a true identity, so that decoder's offset is suspect by exactly 4 bytes.
|
|
DO NOT trust this without re-deriving it; the cheap way is to build the tree
|
|
with IDENTITY matrices first (below), then set one known node's translation
|
|
and watch which slot moves in the fifodump.
|
|
|
|
RECOMMENDED ORDER OF WORK -- two stages, each independently verifiable:
|
|
STAGE 1: walk the .SKL and build the tree with IDENTITY matrices. Every
|
|
node collapses onto the origin so it looks wrong, but the STRUCTURE is
|
|
provable without pixels: the wire must carry 26 DCS flushes and 22 instance
|
|
flushes (MAD.SKL: JointCount=25 +root, DZoneCount=22 -- and the dpl3-revive
|
|
reference capture of a real pod shows exactly 26 and 22). Getting those
|
|
counts proves the recursion, the object loads and the parenting.
|
|
STAGE 2: put the real transforms in and fix the convention by observation.
|
|
|
|
ONE API STILL TO CHECK BEFORE STAGE 1: how the .SKL filename reaches us. The
|
|
engine builds a chain via L4VideoObjectWrapper::BuildVideoObjectChainFromResource
|
|
and rejects our mech with "wrong video resource type for object mad.skl"
|
|
(L4VIDEO.CPP:4265) because it only accepts L4VideoObject::Object / Rubble. So
|
|
the skeleton arrives as a different L4VideoObject::ResourceType -- find that
|
|
enumerator and branch on it in our override rather than re-deriving the name.
|
|
|
|
================================================================================
|
|
THE MECH SKELETON IS BUILT. And the intermittent crash is now THE blocker.
|
|
================================================================================
|
|
BTL4VideoRenderer now answers MechClassID: it walks the video-object chain the
|
|
way the engine does, hands every L4VideoObject::Skeleton entry to ReadSKLFile,
|
|
and recurses the .SKL into a dpl_DCS tree with the geometry hung off it.
|
|
|
|
VERIFIED, repeatedly, on the live pod:
|
|
|
|
[skl] video\mad.skl -> 26 nodes, 19 objects
|
|
no "wrong video resource type" complaint, no geometry load failures
|
|
|
|
and those counts are exactly what the file declares -- MAD.SKL has 25 "joint="
|
|
entries (+1 root = 26) and 19 "Object=" entries. Every joint page became a
|
|
DCS, every Object= became a loaded instance.
|
|
|
|
CORRECTION TO THE EARLIER SUCCESS CRITERION: this file's note previously said
|
|
the walk should produce 22 instances, reading the dpl3-revive capture's
|
|
"instance x22" against MAD.SKL's DZoneCount=22. That was wrong -- damage
|
|
zones are not geometry. There are 28 dzone= tags spread across 19 objects
|
|
(jointshakey alone carries 8 on one .bgf). The right criterion is the file's
|
|
own joint=/Object= counts, which we hit exactly.
|
|
|
|
TRANSLATIONS ARE IN, and the slot question is NOT yet settled: translation is
|
|
currently written to matrix[3]/[7]/[11] (MUNGA's AffineMatrix layout -- 3x4
|
|
row-major, translation in the 4th column, AFFNMTRX.CPP:50). It builds and
|
|
walks cleanly, but no frame has yet been seen WITH the mech, so the choice is
|
|
unconfirmed. Rotation is still identity on purpose (every base-pose
|
|
pitch/yaw/roll in MAD.SKL is 0 or ~1e-3, so translation alone assembles the
|
|
model and isolates one convention at a time).
|
|
|
|
!! A BISECT THAT LIED, worth reading before trusting any pod result !!
|
|
I concluded from single runs that non-identity translations crashed the pod,
|
|
"isolated" it by suppressing the values, and then "confirmed" the other slot
|
|
choice also crashed. All of that was NOISE. Re-running the SAME binary gave
|
|
walk-completes / crash / walk-completes / crash. The intermittent
|
|
plane-write defect ("Reference to a page you don't own", PF at 66D9 -- the
|
|
one with seven hypotheses already killed) is now firing on roughly HALF of
|
|
pod runs, and it lands at different points each time, which is exactly what
|
|
made it look like a deterministic matrix bug.
|
|
|
|
RULE FOR THIS RIG: never accept a single pod run as evidence. Run the
|
|
same binary at least twice and require agreement, or the intermittent
|
|
defect will invent a story for you.
|
|
|
|
That defect has therefore gone from background annoyance to the top of the
|
|
list: it is what stops a mech frame from ever being seen, because a run has
|
|
to survive BOTH the skeleton build AND the launch to render anything, and at
|
|
~50% per run that is a coin flip on a four-minute cycle.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE INTERMITTENT POD CRASH IS THE SKELETON WALK'S -- attributed, not yet fixed
|
|
--------------------------------------------------------------------------------
|
|
Attributed with a same-binary A/B, which is the only clean way to test an
|
|
intermittent fault: BT_NO_SKL skips the skeleton build, so one executable can
|
|
be run both ways with nothing else differing.
|
|
|
|
walk ENABLED ~50% of runs die ("Reference to a page you don't own",
|
|
PF at 66D9), at DIFFERENT points each time -- sometimes
|
|
mid-walk, sometimes after it, sometimes before launch
|
|
walk DISABLED 0 crashes in 2 runs
|
|
SHIPPED exe 0 crashes in 2 runs on the same rig and conf
|
|
|
|
So it is our new code, it is not the rig, and the varying fault point says
|
|
memory corruption surfacing later rather than a bad instruction in the walk.
|
|
|
|
HYPOTHESES KILLED so far (all by reading the private library headers, so they
|
|
stay killed):
|
|
* matrix array too small -- dpl_MATRIX is float32[4][4] (DPL_PRIV.H:137),
|
|
exactly the 16 floats we pass.
|
|
* dangling Object= name. dpl_LoadObject is handed a pointer INTO the
|
|
NotationFile, which we then delete -- but neither s_dplobject
|
|
(DPL_PRIV.H:377) nor the common dpl_node header (DPL_PRIV.H:92) keeps a
|
|
name field, so the library does not retain it there. A private name
|
|
cache is still possible and remains the best surviving suspect.
|
|
* NotationFile rewriting the game's data. ~NotationFile DOES rewrite the
|
|
file when dirtyFlag is set, which would have corrupted the shipped .SKL
|
|
files -- checked, MAD.SKL is untouched (11847 bytes, Jan 2001), and the
|
|
engine's own Verify(!IsDirty()) guard is now copied into ReadSKLFile.
|
|
|
|
STILL TO TRY, cheapest first:
|
|
1. Keep the NotationFile alive for the mission instead of deleting it (a
|
|
~12KB leak) and see if the crash rate drops -- that settles the name
|
|
cache question outright.
|
|
2. Copy each Object= name into storage that outlives the walk, same test.
|
|
3. Count what the walk allocates on the board (26 DCS + 19 objects + 19
|
|
instances + 1 zone per mech) against whatever the VPX HLE's node tables
|
|
allow -- resource exhaustion would also present as a late, varying
|
|
fault.
|
|
4. Check whether MakeEntityRenderables is invoked more than once per mech
|
|
(inside AND outside views). The [skl] summary line printed once per run
|
|
here, so this is unlikely, but it is cheap to re-check with two mechs.
|
|
|
|
BT_NO_SKL stays in the tree: it is how any future "is it the skeleton?"
|
|
question gets answered in one build instead of two.
|
|
|
|
--------------------------------------------------------------------------------
|
|
CORRECTION: THE CRASH IS NOT THE SKELETON WALK'S, AND IT IS NOT RANDOM
|
|
--------------------------------------------------------------------------------
|
|
The previous entry attributed the intermittent page fault to the skeleton walk
|
|
on the strength of "0 crashes in 2 runs" with the walk disabled. That was a
|
|
25% coin flip dressed up as evidence, and it was wrong. Two facts settle it:
|
|
|
|
* The oldest preserved crash log has NO [skl] line and ends with the old
|
|
"couldn't figure out how to MakeEntityRenderables" fallback -- it is from a
|
|
build where the walk did not exist, and it faults identically.
|
|
|
|
* Every dump carries the SAME numbers, to the byte:
|
|
|
|
Reference to a page you don't own
|
|
PF cr2= 7000 FA64 at 00FF 0000 66D9
|
|
Unhandled exception 000E at 00FF 66D9 ErrCode 0004
|
|
|
|
So "it lands at different points each time" was also wrong. What varies is
|
|
how far the log gets, not where the fault is.
|
|
|
|
READING THE FAULT. 0x66D9 is a CODE offset (_TEXT starts at 0, and the DPMI
|
|
host reports EIP relative to the code selector), so btl4opt.map names it
|
|
directly:
|
|
|
|
0x0066C0 EulerAngles::operator=(const LinearMatrix&) <- +0x19 = the fault
|
|
|
|
Disassembling it, +0x19 is `mov eax,[edi]` with edi = ebp-0x10 -- a stack local.
|
|
But the BT_STACK_LOG probe puts the frame at 0x12DEE7, and cr2 is 0x7000FA64,
|
|
1790 MB away. A stack local cannot be there, so the reported EIP is one or two
|
|
instructions past the real one and the actual fault is the preceding
|
|
`fld dword [ebx+0x20]` -- a READ through the matrix reference (ErrCode 4 = read,
|
|
page not present), with ebx = 0x7000FA44.
|
|
|
|
That is still unexplained, because a binary scan of every direct call to 0x66C0
|
|
finds exactly three call sites, and ALL THREE pass `lea eax,[ebp-0x30]` -- a
|
|
stack local. None of them can hand it a 1.79 GB pointer. Unresolved.
|
|
|
|
WHAT THE PROBE KILLED. Two clean negatives, both worth keeping killed:
|
|
|
|
* ESP drift (a calling-convention mismatch marching the stack down a few
|
|
bytes per call). The probe reports drift=0 over 2002 frames, and a scan of
|
|
the whole binary finds exactly ONE callee-cleans function (__virt_decommit,
|
|
a runtime helper) -- everything else is cdecl, so there is nothing to
|
|
mismatch.
|
|
|
|
* A stack too small for our deeper call chains. Our PE and the shipped one
|
|
have identical geometry: 1 MB reserve, 8 KB commit.
|
|
|
|
WHAT THE PROBE FOUND INSTEAD -- and this matters more than the crash. The
|
|
application is parked in state=2, which is LoadingMission, NOT WaitingForLaunch.
|
|
It never advances, because the gate needs IsPriorityEmpty(MinEventPriority) and
|
|
priority 0 is where InterestManager::PostRendererEvent queues every renderer
|
|
event while a mission loads. The queue never drains, the mission never
|
|
launches, and the video renderer holds a blank screen by design until it does.
|
|
The crash arrives after ~30 seconds of that. Runs that DO launch never print a
|
|
single [launch] line -- they sail through the gate -- so "crashes about half the
|
|
time" and "hangs during load" are the same event, seen twice.
|
|
|
|
A TIMING RESULT THAT LOOKS LIKE A BUG AND IS NOT. The gate reposts itself at
|
|
Now()+1.0s and printed 31 times while the stack probe -- which accumulates
|
|
frame_duration to 1.0 -- printed twice. A 15x disagreement between two clocks.
|
|
It traces to `new ApplicationManager(GetTicksPerSecond())` in BTL4.CPP, which
|
|
passes a TICK rate (1000, from TIMESTUB.CPP) where the parameter is a FRAME
|
|
rate, making frameDuration 1ms. That reads exactly like a reconstruction slip,
|
|
so it was checked against the shipped binary before touching anything -- and
|
|
the shipped call site is the same instruction sequence pushing the same kind of
|
|
static float:
|
|
|
|
ours 6a 28 call new 59 8b d8 85 db 74 11 ff 35 a0 11 4e 00 53 call ctor
|
|
shipped 6a 28 call new 59 8b d8 85 c0 74 11 ff 35 0c 14 52 00 53 call ctor
|
|
|
|
So it is authentic. The 15x is just this emulated rig failing to hit a 1000 Hz
|
|
target, which the original hardware would have missed too. DO NOT "fix" it.
|
|
|
|
WHERE THIS GOES NEXT. The open question is why priority 0 drains for the
|
|
shipped exe and not for ours. Application::ProcessOneEvent now counts calls and
|
|
hits (BT_STACK_LOG reports them), which separates the three candidates in one
|
|
run: the pump never runs, the pump runs but finds nothing, or the pump drains
|
|
events as fast as something re-posts them.
|
|
|
|
TOOLING BANKED. emulator/render-bridge/podrun.sh stages the build over
|
|
BTL4REC.EXE, exports the host-side VPX board env (without which the run dies at
|
|
the iserver handshake, not merely unrendered), and archives each run's log --
|
|
marking the file -CRASH when it faulted. The only preserved dump before this
|
|
was an accident, on a rig where every run costs four minutes.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE LOAD GATE: WHAT THE EVENT-PUMP COUNTERS SHOWED
|
|
--------------------------------------------------------------------------------
|
|
Two runs of the instrumented build, both ending in the same fault:
|
|
|
|
[stack] ... over 1001 frames ticks=24729 pump=352/1499
|
|
[stack] ... over 2002 frames ticks=41586 pump=495/2643
|
|
[launch] state=2 minPriorityEmpty=0 ... (31 reposts, then the fault)
|
|
|
|
Reading those numbers rules out most of what "the queue never drains" could
|
|
mean:
|
|
|
|
* The pump RUNS. 1144 ProcessOneEvent calls over 1001 frames is 1.14 per
|
|
frame -- one from the HighEventPriority check at the top of
|
|
ExecuteBackgroundTask, plus one every seventh frame from
|
|
BackgroundTasks::Execute, which round-robins ONE task per call across our
|
|
seven tasks. That 1-in-7 is exactly the rotation, so the early-return path
|
|
is NOT starving the background list.
|
|
|
|
* The pump FINDS WORK. 143 hits per 1001 frames is ~1 per 7 -- essentially
|
|
every ProcessEventTask call succeeds, while the HighEventPriority calls
|
|
find nothing. So events are being processed continuously, at about 8 per
|
|
real second, and priority 0 still never empties after 30+ seconds.
|
|
|
|
* events=0 in the trace is a red herring: ++eventCount lives inside
|
|
#if defined(TRACE_EVENT_COUNT), which this build does not define, so
|
|
GetEventCount() reads 0 no matter what is queued. The probe now prints
|
|
per-priority occupancy instead, which needs no counter.
|
|
|
|
That leaves two candidates, and they want opposite fixes:
|
|
|
|
1. VOLUME. There really are hundreds of priority-0 renderer events and we
|
|
drain them at 8/sec, so the load simply has not finished when the fault
|
|
arrives. Our MakeEntityRenderables loads 19 .bgf objects per mech through
|
|
the EMULATED board, which is not a cheap operation.
|
|
2. DEADLOCK. Priority 0's head is a TIMED event whose alarm never comes due.
|
|
PeekAtNextEvent only returns a timed event when alarmTime <= Now(), but
|
|
IsPriorityEmpty counts it regardless -- so such an event is invisible to
|
|
the pump and permanently visible to the gate.
|
|
|
|
The [queues] probe prints every priority's occupancy each second, which
|
|
separates them: only p0 busy means the deadlock, higher priorities busy means
|
|
we are just watching a long load.
|
|
|
|
RULED OUT ALONG THE WAY. The queue is allocated correctly
|
|
(GeneralEventQueue::Make does new GeneralEventQueue[5], EVENT_PRIORITIES_COUNT
|
|
is 5, MaxEventPriority is 4, so this[priority] indexing is in range).
|
|
Application::Post passes its priority through untouched. The renderer's
|
|
NotifyOfNewInterestingEntity handler consumes its message without re-posting,
|
|
so there is no refill loop from that path.
|
|
|
|
A SEPARATE THREAD WORTH PULLING. The emulator log -- newly captured by
|
|
podrun.sh -- shows a steady trickle of serial1 RX OVERRUN errors on the RIO
|
|
named pipe, and ControlsInstanceEventOf<T> posts at HighEventPriority
|
|
UNCONDITIONALLY (CONTROLS.HPP:250), with no application-state guard. Our log
|
|
also carries "RIO never came back from test mode!" where the shipped binary
|
|
does not. pod_render_norio.conf disables serial1 and changes nothing else, so
|
|
a launch there against a hang here isolates the RIO in one run.
|
|
|
|
--------------------------------------------------------------------------------
|
|
IT IS A REFILL LOOP, AND IT IS DETERMINISTIC
|
|
--------------------------------------------------------------------------------
|
|
Per-priority occupancy, steady state, every second until the fault:
|
|
|
|
[queues] p0=BUSY p1=- p2=BUSY p3=- p4=- nextReady=1
|
|
|
|
* p3 and p4 EMPTY kills the starvation theory outright. Nothing above
|
|
priority 0 is competing, so the pump is free to serve it.
|
|
* p2 busy is this handler's own once-a-second repost. Expected.
|
|
* nextReady=1 means PeekAtNextEvent always has a READY event to hand out --
|
|
so priority 0 is not holding a timed event that never comes due either.
|
|
That kills the deadlock theory.
|
|
|
|
What is left is the only remaining shape: priority 0 is being REFILLED as fast
|
|
as the pump drains it, at ~143 events per simulated second.
|
|
|
|
And it is deterministic. Two runs of the same binary reported pump=352/1499 at
|
|
frame 1001 and 495/2643 at frame 2002 -- identical to the byte. So this was
|
|
never a race, and "crashes about half the time" was always an artifact of
|
|
comparing runs that differed in binary or conf, not in luck.
|
|
|
|
Only InterestManager::PostRendererEvent posts at priority 0 -- it maps every
|
|
renderer event there while the application is not RunningMission -- so naming
|
|
the message names the flooder. Application::Post now tallies priority-0 posts
|
|
by message ID and the [posts] line reports the busiest three. The IDs to
|
|
expect, from RENDERER.HPP against Receiver::NextMessageID = 3:
|
|
|
|
3 NotifyOfNewInterestingEntity 6 StopAllEntityEffects
|
|
4 NotifyOfBecomingUninteresting 7 StartEntityAlarm
|
|
5 StartEntityEffect 8 StopEntityAlarm
|
|
|
|
3 and 4 climbing together would mean entities are oscillating in and out of the
|
|
interest set; 3 alone means something re-adds them; 5 or 7 would point at
|
|
effects or alarms instead.
|
|
|
|
The controlled A/B to run alongside it: pod_render_noskl.conf now carries the
|
|
same probes, so the SAME instrumented binary can be run with the skeleton walk
|
|
on and off. Earlier walk-off runs reached "Turning Plasma Score Display On",
|
|
which is the only configuration of ours known to launch -- if walk-off also
|
|
stops the refill, the walk is implicated in the hang even though it is NOT the
|
|
cause of the fault signature (which predates it).
|
|
|
|
--------------------------------------------------------------------------------
|
|
SOLVED: THE RIO SERIAL PORT WAS BLOCKING EVERY MISSION -- AND A MISSION NOW RUNS
|
|
--------------------------------------------------------------------------------
|
|
pod_render_norio.conf is pod_render_rec.conf with ONE line changed --
|
|
serial1=disabled instead of the vrio named pipe. Same binary, same egg, same
|
|
everything else. Result:
|
|
|
|
[launch] state=2 minPriorityEmpty=0 ticks=84205
|
|
[launch] state=2 minPriorityEmpty=1 ticks=85316 <- backlog finally empty
|
|
[queues] p0=- p1=- p2=BUSY p3=- p4=- <- priority 0 drained
|
|
BTL4Application::RunMissionMessageHandler
|
|
Turning Plasma Score Display On
|
|
[sim] pos=(180.466,10,-358.703) yaw=-0.275605 spd=14.4
|
|
[sim] pos=(251.959,10,-250.498) yaw=-0.89103 spd=14.4
|
|
|
|
The mission LAUNCHES and the mech WALKS under the forced throttle/turn. No
|
|
fault, through 290 log lines and counting.
|
|
|
|
WHAT THE HANG ACTUALLY WAS, after three wrong answers. Not starvation, not a
|
|
timed-event deadlock, not a refill loop -- it was the most mundane candidate on
|
|
the original list, VOLUME, which I talked myself out of:
|
|
|
|
* 530 renderer events queue at priority 0 while a mission loads.
|
|
* BackgroundTasks::Execute runs exactly ONE task per call, round-robin over
|
|
our seven tasks, and the 1ms frame budget is always blown on this rig so no
|
|
extra background passes happen. That is ~9 events drained per REAL second.
|
|
* So a full load legitimately takes ~60 seconds here, and the gate correctly
|
|
stays shut for all of it.
|
|
|
|
The fault arrived at ~40 seconds -- BEFORE the backlog could clear -- which is
|
|
why the load "never" finished. Remove the fault and the same 530 events drain
|
|
to completion and the gate opens. Every "the queue never drains" conclusion
|
|
was really "the process dies before it can".
|
|
|
|
The tell I misread: p0=BUSY with nextReady=1 and a working pump looks like
|
|
refill, but it is equally consistent with a large finite backlog still
|
|
draining. The tally settled it -- id3 climbed to 530 and then went FLAT, which
|
|
means nothing was refilling. I had the disproof in hand a run earlier than I
|
|
acted on it.
|
|
|
|
WHY THE RIO PORT. The conf points serial1 at a vrio named pipe with no server
|
|
attached, which is supposed to behave as an unplugged cable. The emulator log
|
|
(now captured by podrun.sh) shows a steady trickle of serial1 RX OVERRUN errors
|
|
on it, and our build prints "RIO never came back from test mode!" where the
|
|
SHIPPED binary prints "LBE4ControlsManager::Execute, lost RIO analog request"
|
|
and keeps going -- shipped launches and runs the mission on this identical rig.
|
|
So the unplugged port is survivable and our handling of it is not. That is the
|
|
next thread, and it is a real defect rather than a rig artifact: a pod whose
|
|
RIO cable is unplugged should still boot.
|
|
|
|
OPERATIONAL CONSEQUENCE. Until that is fixed, dev-rig runs that do not need
|
|
the RIO should use pod_render_norio.conf. It is the only configuration in
|
|
which our build reaches a running mission, which unblocks everything
|
|
downstream: seeing a mech frame at all, confirming the translation slot, and
|
|
adding rotation.
|
|
|
|
STILL OPEN. The wild reference at EulerAngles::operator=(const LinearMatrix&)
|
|
+0x19 remains unexplained -- cr2 is 1.79GB from a stack whose frame pointer the
|
|
probe shows stable, and all three call sites of that operator pass stack
|
|
locals. It is now reproducible on demand (enable serial1) rather than
|
|
intermittent, which makes it a tractable hunt instead of a coin flip.
|
|
|
|
--------------------------------------------------------------------------------
|
|
FIRST RENDERED FRAME FROM A RUNNING RECONSTRUCTED MISSION
|
|
--------------------------------------------------------------------------------
|
|
emulator/render-bridge/first_mission_frame.png -- the arena city, textured
|
|
buildings, horizon, 28fps, captured from the GL bridge while our build ran a
|
|
live mission with the mech walking under forced throttle. The skeleton went in
|
|
with it: [skl] video\mad.skl -> 26 nodes, 19 objects.
|
|
|
|
CORRECTION TO THE RIO FINDING. I wrote that serial1 pointed at a pipe with no
|
|
server and called it an unplugged cable. That is wrong twice over:
|
|
|
|
* VRio.App is RUNNING on this rig (vRIO v2026.07.16), so the pipe has a
|
|
server -- the dev-rig default since 7/17.
|
|
* The emulator log recorded real byte counts on that port (RX overruns of
|
|
471, 503, 528). An unconnected pipe delivers nothing, so the port was
|
|
live and streaming during the crashing runs.
|
|
|
|
So the defect is NOT "we mishandle a disconnected port". It is "we mishandle a
|
|
LIVE RIO stream", which is worse: every production pod has a real RIO. The
|
|
shipped binary on the same rig prints "LBE4ControlsManager::Execute, lost RIO
|
|
analog request", keeps sending characters (18848 by the time it was killed),
|
|
and runs the mission. Ours prints "RIO never came back from test mode!" and
|
|
then dies partway through the load. The RX OVERRUN counts say the guest is not
|
|
draining the port fast enough, which is the thread to pull: L4RIO.CPP's
|
|
init handshake is authentic archive code, but MechRIOMapper (BT/MECHMPPR.CPP)
|
|
is ours.
|
|
|
|
pod_render_norio.conf remains the way to get a running mission today, but it is
|
|
a WORKAROUND, not the fix -- it disables the cockpit's primary input device.
|
|
THE CAMERA DOES NOT MOVE, AND OUR OWN CODE EXPLAINS IT. Two frames captured
|
|
seconds apart are pixel-identical while [sim] shows the mech travelling from
|
|
(180.4, 10, -358.7) to (-604.8, 0, -352.4), and the bridge title reads
|
|
cam (0.0, 10.0, 0.0) throughout. The scene renders; the eyepoint is never
|
|
driven.
|
|
|
|
That is our MakeEntityRenderables doing exactly what it was written to do. When
|
|
ReadSKLFile finds a Skeleton entry it sets handled = True, and the chain to
|
|
DPLRenderer::MakeEntityRenderables is then SKIPPED -- but that is the call that
|
|
builds the RootRenderable and the DPLEyeRenderable which the camera follows
|
|
(L4VIDEO.CPP:4548-4566). So we hand the board a skeleton with no root and no
|
|
eye.
|
|
|
|
Which closes the loop on the fault, too: the eyepoint construction we are
|
|
skipping is the same code that faults when it does run. The mech case has to
|
|
end up doing BOTH -- the engine's root/eye renderables AND our skeleton -- so
|
|
the EulerAngles::operator=(const LinearMatrix&) fault has to be understood
|
|
before the mech can be seen from its own cockpit. It is now reproducible on
|
|
demand rather than intermittent, which is the difference between a hunt and a
|
|
coin flip.
|
|
|
|
--------------------------------------------------------------------------------
|
|
CORRECTION: THE FAULT ADDRESS WAS NEVER IN OUR CODE
|
|
--------------------------------------------------------------------------------
|
|
Every conclusion in this file that named EulerAngles::operator=(const
|
|
LinearMatrix&) as the fault site was wrong, including the disassembly arguments
|
|
built on top of it. Three independent proofs:
|
|
|
|
1. THE PROBE PRINTED NOTHING. ROTATION.CPP is ours, so the function was
|
|
instrumented directly -- print this, &matrix and a local's address on the
|
|
first twelve calls, plus any call with a wild pointer. The run faulted
|
|
with ZERO [euler] lines. The function was never called at all.
|
|
|
|
2. THE INSTRUCTION AT THE FAULT ADDRESS CANNOT FAULT. After the probe was
|
|
added, 0x66D9 disassembles to `jnl 0x66f8` -- a two-byte conditional jump
|
|
that touches no memory. The dump reports ErrCode 0004, a data READ.
|
|
|
|
3. THE ADDRESS NEVER MOVES. EIP 0x66D9 and cr2 0x7000FA64 are identical to
|
|
the byte across every build this session, including builds where the code
|
|
at that offset changed completely (it was `mov eax,[edi]` before). A real
|
|
fault in our CODE segment moves when the code moves. This one does not,
|
|
which means it lives in something that does not get relinked: the DPMI
|
|
host (32rtm / DPMI32VM.OVL), which loads low.
|
|
|
|
The map lookup that started it all was a COINCIDENCE. Our _TEXT is
|
|
section-relative from 0, so its offsets overlap the host's low addresses, and
|
|
btl4opt.map will cheerfully name a function for any small number handed to it.
|
|
The lesson is specific and worth keeping: before resolving a fault address
|
|
against the map, confirm the address actually BELONGS to our segment -- the
|
|
cheapest check being whether it moves when the binary is relinked.
|
|
|
|
WHAT IS ACTUALLY TRUE, by same-binary A/B this morning:
|
|
|
|
vRIO process DOWN, serial1 still a namedpipe -> mission LAUNCHES
|
|
vRIO process UP, same conf, same binary -> FAULT
|
|
|
|
So the trigger is a LIVE RIO STREAM, not the port's existence and not an
|
|
unplugged cable. Supporting evidence: the emulator reports continuous serial1
|
|
RX OVERRUN errors (the guest is not draining the port), the fault lands at
|
|
whatever the app happened to be doing (terrain on one run, the mech's inside
|
|
view on another), and the SHIPPED binary survives the identical conditions --
|
|
it logs "lost RIO analog request", keeps sending characters, and runs.
|
|
|
|
A fault at a fixed host address, landing at arbitrary points in the
|
|
application, that requires an interrupt-driven data stream, is an INTERRUPT
|
|
RE-ENTRANCY signature, not a wild pointer in our data. That is where the hunt
|
|
goes next: the RIO ISR path and what it re-enters. L4RIO.CPP is authentic
|
|
archive code; MechRIOMapper (BT/MECHMPPR.CPP) and the controls plumbing are
|
|
ours.
|
|
|
|
REPRODUCING IT: start VRio.App, run pod_render_rec.conf. Stop vRIO (or use
|
|
pod_render_norio.conf) for a clean mission.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE MECH NOW GETS BOTH SKELETON AND ENGINE RENDERABLES -- CAMERA STILL FROZEN
|
|
--------------------------------------------------------------------------------
|
|
BTL4VideoRenderer::MakeEntityRenderables no longer stops after the skeleton.
|
|
It builds ours AND chains DPLRenderer, which is what creates the
|
|
RootRenderable and the DPLEyeRenderable. Verified over three runs:
|
|
|
|
[mer] 208 class=3001 view=1 res=1
|
|
[vid] type=1 file='mad.skl'
|
|
[skl] video\mad.skl -> 26 nodes, 19 objects
|
|
[chain] into DPLRenderer, skeleton=yes, stack at 0x12dde7
|
|
|
|
No fault, mission launches, sim runs. Worth noting for its own sake: the
|
|
engine's eyepoint construction -- the prime suspect for the page fault through
|
|
most of a session -- runs perfectly here, and the stack at that point is
|
|
0x12dde7, right beside the main loop's frame. Both facts are consistent with
|
|
the corrected finding that the fault is a live-RIO artifact at a fixed DPMI
|
|
host address.
|
|
|
|
BUT THE CAMERA STILL DOES NOT MOVE. Two frames six seconds apart differ by
|
|
ZERO pixels while [sim] reports the mech travelling from (487,20,288) to
|
|
(493,20,301), and the bridge title reads cam (0.0, 10.0, 0.0) throughout --
|
|
the world origin plus eye height.
|
|
|
|
So building the eye renderable was necessary but not sufficient, and the gap is
|
|
downstream. What has been ELIMINATED, by reading our own code rather than
|
|
guessing:
|
|
|
|
* The viewpoint entity IS the mech. BTL4Application::MakeViewpointEntity's
|
|
MechClassID branch does viewing_entity = Mech::Make(...) (BTL4APP.CPP:354).
|
|
* The video renderer IS linked to it. MakeAndLinkViewpointEntity calls
|
|
videoRenderer->LinkToEntity(viewpointEntity) (APP.CPP:1337).
|
|
* SetupCull would Fail() loudly if a JointedMover lacked "siteeyepoint" or
|
|
the EyepointRotation attribute. Neither Fail appears, so the mech takes
|
|
the JointedMover branch and both are present.
|
|
|
|
That leaves the path from SetupCull's worldToEyeMatrix to what the bridge
|
|
actually reads off the wire. cam (0.0, 10.0, 0.0) looks like the BRIDGE's own
|
|
default eye height (FP_UPOFF) never being displaced by a view from the game,
|
|
which points at the eyepoint compose in live_bridge rather than at the
|
|
reconstruction. That is its own sub-project -- see COCKPIT-CAGE-NOTES.md,
|
|
where the eyepoint/glance compose is already documented territory.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE CAMERA GAP, ROOT-CAUSED END TO END (three layers deep)
|
|
--------------------------------------------------------------------------------
|
|
LAYER 1 -- our code. "Chain the engine after the skeleton" was a non-fix: the
|
|
engine's MakeEntityRenderables only accepts Object/Rubble resources, so
|
|
chaining it with a Skeleton printed "wrong video resource type" (it was there
|
|
in the log, line 344, both chain runs) and built NOTHING. No RootRenderable,
|
|
no eye, no articulation -- and the fifodump proved it: ZERO vr_flush_dcs_artic
|
|
(0x1f) records against 6667 one-shot flushes. The bridge decoder was fine;
|
|
the game genuinely never articulated anything.
|
|
|
|
The real fix mirrors the engine's own Mover composition (L4VIDEO.CPP:
|
|
4795-4860) inside OUR mech case, because the base cannot do it for skeletons:
|
|
|
|
RootRenderable(entity, Dynamic, NULL, dplMainZone, ...) <- per-frame flush
|
|
ReadSKLFile(entity, this_root->GetDCS(), ...) <- skeleton UNDER it
|
|
if inside: DPLEyeRenderable(..., root_DCS, dplMainView,
|
|
GetAttributePointer("EyepointRotation")) <- the camera
|
|
|
|
ReadSKLFile grew a parent_dcs parameter for this. NULL keeps the old
|
|
add-to-scene behaviour. Also explains the mystery monolith in the first
|
|
frame: the skeleton was parked AT THE WORLD ORIGIN with the camera at
|
|
origin+eye-height -- we were looking at our own mech from inside its shins.
|
|
|
|
RESULT: anim_abs went 0 -> 1 on the bridge (first articulation our build has
|
|
ever put on the wire), the bridge camera left the origin -- cam (54.2, 20.0,
|
|
-662.5), tracking the mech's neighbourhood at its correct Y -- and the bridge
|
|
even flagged all 19 own-mech geometry instances as drawn around the eye.
|
|
|
|
LAYER 2 -- the 1995 library, read from our own linked binary (the map is
|
|
trustworthy here because these are real linked symbols, not a stray fault
|
|
address): dpl_DrawSceneComplete = velocirender_frameack(0). frameack keeps a
|
|
"frame acked" flag; when a reply arrives whose action is NOT 9 it prints
|
|
"dpl error - unsolicited input during frame ack", adds a puzzled
|
|
"flush artic??" when that stray action is 0x1f -- the 1995 authors' own
|
|
annotation -- and calls exit(9). Worth knowing before it ever fires.
|
|
|
|
LAYER 3 -- the EMULATOR, where the actual deadlock lived. With per-frame
|
|
articulation flowing for the first time, the game stopped drawing at frame 232:
|
|
one 0x1f burst per frame forever after, no receives, no draws, no error, sim
|
|
fine. The VPX device feeds the frame ack only after POLL_THRESHOLD=6
|
|
CONSECUTIVE empty inputStatus polls, and vpxlog.cpp:669 reset that counter on
|
|
EVERY outputData write ("a write means the game isn't blocking-reading").
|
|
Every frame now writes an articulation burst, the game polls once or twice a
|
|
frame, the counter never reaches 6, dpl_DrawSceneComplete never goes true.
|
|
The heuristic was calibrated on traffic that had no per-frame writes; ours is
|
|
the first game state that both articulates and draws through this device.
|
|
|
|
Fix (vpx-device/vpxlog.cpp, synced to src/src/hardware): the reset is now
|
|
gated on !frame_outstanding -- once a draw_scene is outstanding, the only
|
|
receive the game will do next is the frame ack, so intervening writes must not
|
|
cancel the poll accumulation. The iserver-drain concern the reset guarded is
|
|
a boot-time state where no frame is ever outstanding.
|
|
|
|
STATUS: emulator rebuilding; verification = the same norio run drawing PAST
|
|
frame 232 with the camera tracking, then two agreeing runs before belief.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE MECH STANDS: MATRIX CONVENTION PROVEN FROM ENGINE SOURCE, CONFIRMED VISUALLY
|
|
--------------------------------------------------------------------------------
|
|
emulator/render-bridge/pose_chase.png -- the MadCat assembled from its 19
|
|
parts, standing in the arena at its live articulated position (chase render of
|
|
the live fifodump, framed on anim_abs root 0x672). Feet, legs, torso, weapon
|
|
pods, canopy plate with the purple glass panels. Not a collapsed stack.
|
|
|
|
THE CONVENTION, now read from the engine rather than inferred from captures:
|
|
RootRenderable's ctor writes an entity pose into a DCS via
|
|
|
|
*(Matrix4x4*)dpl_GetDCSMatrix(myDCS) = myEntity->localToWorld;
|
|
|
|
so Matrix4x4::operator=(const AffineMatrix&) (MATRIX.CPP:130) IS the wire
|
|
layout: Matrix4x4 is ROW-major (entries[(Row<<2)+Column], MATRIX.HPP), rotation
|
|
in rows 0-2, ZEROS in column 3, TRANSLATION IN ROW 3 = entries 12/13/14.
|
|
|
|
The previous choice (translation at 3/7/11) rested on two errors, both now
|
|
corrected in RecurseSKLFile's comments: (1) AffineMatrix DOES keep translation
|
|
at entries 3/7/11 -- but because it is COLUMN-major (entries[(column<<2)+row],
|
|
AFFNMTRX.HPP:99), that is its (3,c) translation ROW by storage; Matrix4x4
|
|
transposes on copy. Citing it as precedent for a row-major layout was reading
|
|
the storage and ignoring the indexer. (2) "translation in the last row made
|
|
the maths blow up" came from the contaminated bisect -- those crashes were the
|
|
RIO fault.
|
|
|
|
Rotation now composes through the engine's own Matrix4x4::operator=(const
|
|
EulerAngles&) from the page's pitch/yaw/roll, so the rotation order is the
|
|
engine's by construction. Node write uses the RootRenderable idiom
|
|
(dpl_GetDCSMatrix + assign), not dpl_SetDCSMatrix.
|
|
|
|
Tooling: the chase-frame variant of fifobridge (camera framed on the
|
|
articulated root instead of arena bounds) is the ten-line proof harness for
|
|
any future pose question -- offline, against a copy of the live dump, zero
|
|
interference with the running pod.
|
|
|
|
REMAINING VISUAL WORK, in order of blockingness:
|
|
* Eye height: the inside view sits at hull Y+UPOFF and stares through the
|
|
torso panels. The engine's answer is the siteeyepoint segment compose
|
|
(SetupCull) -- our skeleton has the joint chain, so the eye DCS should ride
|
|
jointcockpit/siteeyepoint rather than the root. COCKPIT-CAGE-NOTES.md
|
|
territory (glance-hide / punch cutout) applies once the height is right.
|
|
* Proportions/orientation fine-tune once a cleaner outside frame exists;
|
|
limbs may need per-joint rotation signs checked against a shipped capture.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE LIVE-RIO FAULT: THREE ELIMINATIONS AND ONE NEW DEFECT
|
|
--------------------------------------------------------------------------------
|
|
Same-day follow-ups on the RIO fault (cr2 7000FA64 / EIP 66D9, DPMI-host
|
|
address), all on the ack-fixed emulator:
|
|
|
|
* NOT the ack heuristic: the fault reproduces identically on the fixed
|
|
emulator (vRIO up -> fault, one run).
|
|
* NOT the debug logging: pod_render_quiet.conf (no BT_* env, minimal COM3
|
|
traffic) faults the same. The COM3-write-holds-off-the-ISR theory is
|
|
dead.
|
|
* NOT the failed test-mode handshake: the quiet run's init PASSED (zero
|
|
"RIO never came back" lines) and it faulted anyway. Init failure and the
|
|
fault are fellow travellers, not cause and effect.
|
|
|
|
The Thrustmaster isolator (pod_render_tm.conf: L4CONTROLS=THRUSTMASTER so the
|
|
game never opens the RIO while vRIO still streams into serial1) was blocked by
|
|
its own discovery: TM mode dies at boot in
|
|
ControlsInstanceDirectOf<int>::Update(void*)+0x6 -- mov eax,[eax+0x1c] with
|
|
EAX=0, guest address 00485E1A, which resolves CLEANLY in our map and is a REAL
|
|
defect in our controls wiring for the non-RIO path. (Applying the new rule:
|
|
this one is genuinely ours -- the module/offset line names BTL4REC.EXE CODE
|
|
0x75E1A directly.) So TM mode is its own reconstruction brick, and the RIO
|
|
isolation experiment needs it fixed first.
|
|
|
|
Facts that survive for the next session: OUR exe + live vRIO stream = fault in
|
|
~30-40s; SHIPPED exe + the same stream on the same emulator = runs fine (two
|
|
baselines). Our binary differs in the per-event path the RIO stream drives:
|
|
ISR and LBE4ControlsManager are archive code, but the mapper (BT/MECHMPPR.CPP)
|
|
and the entire event/receiver plumbing (MUNGA/APP.CPP, RECEIVER.CPP) are ours.
|
|
RIO_TAP produced no file on this path -- byte-level dialogue comparison needs
|
|
a different tap point.
|
|
|
|
--------------------------------------------------------------------------------
|
|
CORRECTION FROM THE OPERATOR: pose_chase.png WAS UPSIDE DOWN -- HARNESS, NOT WIRE
|
|
--------------------------------------------------------------------------------
|
|
The operator caught it immediately ("-y is up here"): the 5.3.65 milestone
|
|
image showed the mech hanging from its own ground-shadow plate. The follow-up
|
|
established exactly what was and wasn't wrong, and the reconstruction survives
|
|
untouched:
|
|
|
|
WHAT IS RIGHT (three independent proofs, wire-level):
|
|
* MAD.SKL offsets are a coherent Y-UP model: hip +5.29 above ground, thigh
|
|
+0.24, knee -1.97 and ankle -2.39 descending, torso/missile mounts/eye
|
|
ascending. Anatomically consistent ONLY read +Y-up.
|
|
* The BGF vertex extents agree with the offsets: foot meshes extend -1.0
|
|
below their ankle joint (the sole), toe meshes -0.65 below their joints,
|
|
the torso mesh +3.8 above shakey (to the missile-pod tops), thigh meshes
|
|
reach down past the knee joint. One space, both data families.
|
|
* THE SHIPPED POD'S WIRE CARRIES THE SAME SIGNS. bt4.raw.bin's mech
|
|
skeleton: hip node (0,+6.21,0), knees negative, paired thighs +/-1.71,
|
|
and the vehicle root (df1) is a PURE YAW (+1 on the Y diagonal, det +1) at
|
|
its world position -- no flip anywhere between game and board, in either
|
|
game. Our wire and shipped's are convention-identical.
|
|
|
|
WHAT WAS WRONG: the ad-hoc chase camera in the 5.3.65 render. It borrowed
|
|
fifobridge's aerial up-hint ([0,-1,0], correct for the calibrated LIVE-bridge
|
|
world where -y is up) and applied it to a raw scene-space render of Y-up wire
|
|
data -- screen-up landed on model-down, mech hangs from its shadow, and the
|
|
symmetric checkerboard ground disguised it. The shipped capture rendered
|
|
upside-down through the same harness, which is what cleared the wire.
|
|
|
|
THE TWO CONVENTIONS, so nobody trips on this again:
|
|
* WIRE / scene space: Y-UP. Both games, models and skeletons alike.
|
|
* LIVE bridge presentation: -y is up. The calibrated path (fp_cam, the
|
|
mirrored det -1 root basis, FP_RIGHT_SIGN / CAGE_TWIST_SIGN family) maps
|
|
between them; COCKPIT-CAGE-NOTES.md documents that machinery.
|
|
|
|
TOOLING: pose_probe.py replaces the throwaway harness -- updir=+1 default
|
|
(scene space), updir=-1 reproduces the bridge-world sense. Verified:
|
|
pose_chase_upfixed.png shows the MadCat right side up -- chicken-walker legs,
|
|
purple knee actuators, feet planted, shadow quad UNDER the feet, weapon-pod
|
|
arms. The milestone stands, now with the correct picture.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE LIVE-RIO FAULT AND THE TM BOOT CRASH WERE ONE DEFECT: THE TORSO TABLE
|
|
--------------------------------------------------------------------------------
|
|
The [map] audit (BT_MAP_LOG in BTL4APP.CPP, printed just before the archive's
|
|
CreateStreamedMappings consumes the streamed table) named it in one run:
|
|
|
|
[map] BAD direct: idx=1 group=3 elem=68 subsysID=17 attrID=12 (attr NULL)
|
|
[map] BAD direct: idx=2 group=3 elem=67 subsysID=17 attrID=13 (attr NULL)
|
|
|
|
Subsystem 17 = Torso (the [roster] dump). The donor's decompiled torso.hpp
|
|
carries the authentic enum WITH binary offsets: ids 3..15 =
|
|
RotationOfTorsoVertical..SpeedOfTorsoHorizontal (3-8), then StickPosition(9),
|
|
TorsoUp(10), TorsoDown(11), TorsoLeft(12), TorsoRight(13), TorsoCenter(14),
|
|
MotionState(15). Our table had SEVEN entries with MotionState at id 9, on
|
|
the strength of a comment that said "TorsoUp/Down/etc are MESSAGES, not
|
|
attributes". Wrong -- and wrong in a specifically nasty way, because the
|
|
streamed control mappings bind BY ID as DIRECT WRITE DESTINATIONS
|
|
(L4CTRL.CPP:1936 registers GetAttributePointer(attributeID) as the pointer a
|
|
device poll writes through):
|
|
|
|
* TM mode: button ids 12/13 resolved NULL -> the boot write-fault at
|
|
ControlsInstanceDirectOf<int>::Update+0xE the moment the PC joystick
|
|
polled. Instant, clean, easy.
|
|
* RIO mode: analog id 9 (StickPosition) resolved to our motionState
|
|
STATEINDICATOR -- so every analog packet from a live vRIO wrote raw
|
|
floats over a watcher-socketed object. Corrupted socket chains walked
|
|
into unmapped memory ~30-40s later: the "intermittent" pod fault at the
|
|
fixed DPMI-host address, the one that ate two days. vRIO down = no
|
|
analog stream = no corruption = mission runs. Every observation from
|
|
the whole hunt drops out of this one mechanism.
|
|
|
|
FIX: Torso publishes the full 13-entry donor table; the five int command
|
|
members are carved from the front of the dynamicsState reserve (class size
|
|
unchanged); ctor zeros them. SpeedOf* still point at base rates until
|
|
TorsoSimulation brings the live velocity accumulators.
|
|
|
|
VERIFIED, two agreeing runs each way:
|
|
* TM mode: launches, [map] audit clean, and the Thrustmaster joystick
|
|
DRIVES the torso -- stickY=0.907 -> torsoElev/eyePitch=0.349. The
|
|
non-RIO controls path lives.
|
|
* RIO mode with vRIO STREAMING: launches, zero faults, mission runs deep
|
|
enough that weapons cycle. pod_render_rec is no longer poisoned; the
|
|
norio workaround is obsolete.
|
|
|
|
Incidentally the [euler] probe now prints during missions with sane
|
|
stack-local pointers -- EulerAngles::operator=(const LinearMatrix&) was
|
|
always innocent, now visibly so.
|
|
|
|
METHOD NOTE for the file: the audit-before-the-archive-call pattern (walk the
|
|
same streamed table our code hands to archive code, name what will not
|
|
resolve) turned a two-day intermittent heap-corruption hunt into a one-run
|
|
lookup. The streamed RES tables are BINDING CONTRACTS on our attribute
|
|
enums; every subsystem the RES maps should get the same audit treatment.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE COCKPIT EYE SITS IN THE CANOPY -- SITE HANDLING IN THE SKELETON WALK
|
|
--------------------------------------------------------------------------------
|
|
The eye no longer rides the hull root. RecurseSKLFile now walks "site="
|
|
children (sites get NO draw component -- which is WHY the walk's 26 nodes
|
|
matched the real capture: sites were never DCS nodes), and siteeyepoint
|
|
spawns the cockpit camera with the donor's exact construction (bt411
|
|
btl4vid.cpp:462, decomp FUN_004579a8):
|
|
|
|
offset = the SITE's own local rest transform (siteeyepoint = identity)
|
|
parent = the site's PARENT joint's DCS (jointeye, NOT the root)
|
|
|
|
so world orientation, torso twist and gait all arrive through chain
|
|
composition, with no per-mech forward-axis assumption. The old root-DCS eye
|
|
remains only as the zero-construction fallback for a skeleton with no
|
|
siteeyepoint (the engine's own default, L4VIDEO.CPP:4849).
|
|
|
|
MEASURED on the live pod (RIO streaming, post-5.3.68): [eye] cockpit eye on
|
|
'jointeye', [skl] 26 nodes / 19 objects / 1 eye, and the bridge camera sits at
|
|
Y = hull + 7.7 -- the exact chain sum jointlocal 5.29 + hip 0.37 + torso 0.31
|
|
+ jointeye 1.69 = 7.66. canopy_eye1.png: the pilot's view, with the MadCat's
|
|
own nose wedge visible below the sightline -- own-mech cage handling
|
|
(COCKPIT-CAGE-NOTES.md punch cutouts) is the known next layer.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE FULL DIRECT-MAPPING CONTRACT, AUDITED -- AND THE FAULT IS NOT FULLY DEAD
|
|
--------------------------------------------------------------------------------
|
|
The canopy-eye confirming run (post-5.3.68 build #5 with vRIO live) FAULTED
|
|
with the old signature at ticks 53195, during the load drain. Four clean runs
|
|
then one fault: the Torso fix removed the dominant corruption source but not
|
|
all of it, or something else entirely is still alive. The two-runs rule keeps
|
|
earning its keep.
|
|
|
|
So the audit got extended from "flag NULLs" to "print the whole resolved
|
|
contract". The L4 (RIO) list carries 13 DIRECT mappings (the other 96 are
|
|
event mappings, which dispatch through handler tables rather than raw pointer
|
|
writes):
|
|
|
|
mapper (sub=0, cls=2012): id 3 joystick -> stickPosition (8-byte write)
|
|
id 4 scalar -> throttlePosition
|
|
id 6 button -> reverseThrust
|
|
id 10/11/12 btn -> lookLeft/lookRight/lookBehind
|
|
Torso (sub=17, cls=3013): id 14 button -> torsoCenter (donor: 14 = TorsoCenter, matches)
|
|
6 weapons (cls 3016/3021/3024): id 19 btn -> TriggerState (the known 0x13 binding)
|
|
|
|
Every destination resolves, every SIZE is right (ControlsJoystick =
|
|
Vector2DOf<Scalar> = 8 bytes lands in an 8-byte member; buttons are ints into
|
|
ControlsButton ints). One TYPE wart, noted not fixed: MechWeapon binds
|
|
TriggerState onto fireImpulse, which is a Scalar -- a ButtonGroup direct
|
|
writes an INT bit-pattern there, so a real trigger press stores 1 as
|
|
0x00000001 = 1.4e-45f. Size-safe (no corruption) but the fire FSM reading it
|
|
as a float threshold will never see a real button press as a pull -- the RIO
|
|
trigger is likely non-functional until TriggerState gets an int home. (Fire
|
|
worked in tests via the BT_FORCE_FIRE hook, which writes the member as a
|
|
proper float -- which is exactly why this never showed.)
|
|
|
|
With the direct contract clean, the surviving fault theories are:
|
|
1. A second corruption source OUTSIDE the direct mappings (event-mapping
|
|
payload mis-typing; a watcher writing through a stale pointer).
|
|
2. Not corruption at all: an emulator/DPMI-host interaction in serial IRQ
|
|
delivery whose probability the Torso fix merely CHANGED by changing
|
|
guest timing. The fault's constants -- same host EIP, same cr2, always
|
|
during the load drain -- fit this better than they ever fit the
|
|
corruption story, where the smash point should move with our data
|
|
layout (it never did, across builds where our BSS moved).
|
|
3. Sampling noise on a genuinely rarer residual of theory 1.
|
|
|
|
A 4-run streak (riostreak.sh) is measuring the post-fix rate now. If the
|
|
rate holds nonzero, the next probe is EMULATOR-side: hook the fork's paging
|
|
on cr2==0x7000FA64 and dump the last serial-IRQ deliveries + guest cs:ip
|
|
history -- the AWE debug kit pattern, pointed at the UART.
|
|
CORRECTION to the TriggerState wart above, same day: there is no wart. Our
|
|
CheckFireEdge (MECHWEAP.CPP:275) already compares fireImpulse's BIT PATTERN as
|
|
a signed int -- sign-correct for the button ints the direct mapping bit-copies
|
|
in AND for genuine float values, reproducing the binary's x87 behavior at
|
|
004b9608. The donor types the member Scalar exactly as we do, and the binary
|
|
itself binds TriggerState to fireImpulse. The RIO trigger path is sound as
|
|
built; the 5.3.70 commit message's claim that a real press could never fire
|
|
was wrong -- written before reading our own edge detector.
|
|
|
|
--------------------------------------------------------------------------------
|
|
POST-FIX FAULT RATE MEASURED; TORSO BUTTON COMMANDS WIRED INTO THE SIM
|
|
--------------------------------------------------------------------------------
|
|
riostreak.sh, four consecutive vRIO-live runs: CLEAN(88228) CLEAN(93313)
|
|
FAULT(42274) CLEAN(92822). Combined post-Torso-fix tally: 8 runs, 2 faults,
|
|
~25%%, and the pattern is rigid -- every fault lands in the LOAD WINDOW
|
|
(ticks 42-53k), every clean run survives to launch (88k+) and then runs
|
|
indefinitely. Pre-fix the rate was 3/3.
|
|
|
|
The reframe that matters: our load window is ~60s of slow event drain;
|
|
shipped's is roughly a quarter of that. If the residual fault is a constant-
|
|
rate hazard during load (~0.5%%/s from our numbers), shipped's expected rate
|
|
is only ~7%% per run -- and shipped has had maybe five runs on this rig.
|
|
"Shipped survives the same stream" may be nothing but exposure luck on a
|
|
shorter window. Combined with the constants that never fit guest corruption
|
|
(fixed host EIP, fixed cr2 across builds whose BSS moved), the residual is
|
|
now MOST LIKELY an emulator-fork serial/DPMI-host interaction, not a game
|
|
defect. Next probe when this thread resumes: the AWE-debug-kit pattern
|
|
pointed at the UART -- hook the fork's page-fault path on cr2==0x7000FA64 and
|
|
dump the last serial-IRQ deliveries + guest cs:ip history. (And run a
|
|
shipped-exe streak for the baseline exposure comparison.)
|
|
|
|
MEANWHILE, the torso BUTTON path is real end-to-end: TorsoSimulation carries
|
|
the donor's digital command handling (torso.cpp:557, binary @004b5cf0) --
|
|
elevate up/down, twist left/right with limit clamps, centre-button recenter
|
|
latch slewing home across frames, and the ramp machinery kept verbatim
|
|
including the binary's punchline (the shipped build unconditionally
|
|
overwrites the ramp with 1.0f -- authored dead weight, preserved as the
|
|
binary's shape). recenterActive carved from dynamicsState (class size
|
|
unchanged). With 5.3.68's table fix, the RIO/TM twist buttons now land in
|
|
these members and move the torso -- and the canopy eye rides the twist chain.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE RESIDUAL FAULT, CAUGHT IN THE ACT -- AND THE INSIDE VIEW GETS ITS COCKPIT
|
|
--------------------------------------------------------------------------------
|
|
TWO instruments landed together this session:
|
|
|
|
1. VPX_PF_WATCH (emulator fork, cpu/paging.cpp): on any guest page fault at
|
|
the watched linear address, dump guest registers, the last 64 serial RX
|
|
deliveries (time + guest cs:eip at delivery + byte), the code bytes at the
|
|
faulting EIP and the stack top. serialnamedpipe's doReceive() feeds the
|
|
ring (VPX_SerialTraceByte). Armed via VPX_PF_WATCH=7000FA64 in podrun.sh
|
|
and launch_pod.ps1; the dump lands in dosboxlog.txt / pod_err.txt.
|
|
|
|
FIRST CATCH decoded the fault completely:
|
|
CS:EIP = 00FF:000066D4 (the DPMI host, flat selector)
|
|
EBX = F000CA60 <- an IVT ENTRY read as a dword: seg F000, off CA60
|
|
= a BIOS DEFAULT interrupt handler
|
|
access = [EBX+0x3004], and the segment's 0x80000000 base wrap gives
|
|
exactly cr2 = 0x7000FA64
|
|
So the host reads a probe word 0x3004 bytes past a REAL-MODE VECTOR VALUE
|
|
treated as a flat pointer. For the host's own handlers (low memory) that
|
|
read lands in mapped conventional memory and is harmless; for a BIOS
|
|
F000:xxxx default it lands in unmapped high linear space and faults. The
|
|
serial ring shows a steady 1-byte/1-3ms vRIO stream with NOTHING special
|
|
at the fault -- the stream only determines WHICH vectors the host walks
|
|
and when. The +0x3004 constant appears nowhere in DPMI32VM.OVL or
|
|
32RTM.EXE as an immediate, so it is composed at runtime; the probe now
|
|
also dumps code bytes at EIP + stack to close that gap on the next catch
|
|
(faulthunt.sh loops runs until it fires).
|
|
|
|
2. SHIPPED BASELINE: 4/4 clean. Consistent with the exposure theory
|
|
(expected ~0.6 faults if the hazard is proportional to the load window)
|
|
-- not yet discriminating. The probe data is the discriminator now.
|
|
|
|
THE INSIDE VIEW LOADS THE COCKPIT SKELETON. MAX.SKL solved the own-mech
|
|
question the authentic way: the X-variant naming convention (third letter X:
|
|
MAD->MAX, AVA->AVX, BAT->BAX, BLH->BLX, FIR->FIX, JAK->JAX, LOK->LOX, plus
|
|
numbered MAX1/MAX2 -- all 64 present in VIDEO/) selects a skeleton with the
|
|
SAME 25-joint chain but a single object: max_cop.bgf, the MAX_COP canopy
|
|
shell with the PUNCH-texel windows from the real-pod capture forensics. The
|
|
donor names the same mechanism from the decomp side (inside = SkeletonType_A
|
|
with '_cop' selection, btl4vid.hpp:678). Our mech case now derives the
|
|
inside name from the resource's skeleton (fallback to the body skeleton if
|
|
the X file is missing), so the pilot sits in the authentic cockpit instead
|
|
of inside the body model's torso panels.
|
|
|
|
--------------------------------------------------------------------------------
|
|
FAULT HUNT ROUND 2: THE BAD POINTER IS THE EMULATOR'S DEFAULT-HANDLER STUB
|
|
--------------------------------------------------------------------------------
|
|
The IVT scan on the second catch settles what EBX is: F000:CA60 is the
|
|
EMULATOR'S DEFAULT "unhandled interrupt" callback, the value filling every
|
|
vector nobody hooked -- it matched ~56 vectors (INT00, 06, 07, 2B-2D, and
|
|
most of 32-6F). Meanwhile the hardware-IRQ block IVT[08-0F] reads
|
|
0D1F:0777..0793 -- the DPMI host's own real-mode reflector stubs, 4 bytes
|
|
apart. So the serial IRQs ARE properly hooked; the host read the default
|
|
value from one of the UNHOOKED vectors and probed [value + 0x3004] as a flat
|
|
pointer, which wraps under the segment base to the famous cr2.
|
|
|
|
Register constants across all catches (EAX=1 EBX=F000CA60 ECX=0 EDX=FFFFFFFF,
|
|
same ESP): a deterministic host code path, not wreckage. Two more facts from
|
|
this round: the "stack" dwords from catch #1 decode as 16-bit REAL-MODE code
|
|
-- because the dump read ESP as a bare linear address; SS=0107 has a nonzero
|
|
base. Fixed (SegPhys(ss)/SegPhys(cs) now applied; rebuilt, ready for the
|
|
next catch). And the code bytes at linear EIP read as ZEROS -- consistent
|
|
with DPMI32VM being a VIRTUAL-MEMORY host that pages its own regions: the
|
|
page holding the faulting code may already be evicted when the dump runs.
|
|
"Reference to a page you don't own" is the host PAGER's message: the fault
|
|
address falls outside every region it owns, so it aborts instead of paging.
|
|
|
|
WHAT'S NAILED: host-side deterministic path; trigger correlated with the
|
|
serial stream's presence but not with any particular byte; the bad read =
|
|
[unhooked-vector-default + 0x3004]. WHAT'S OPEN: which vector, and which
|
|
host routine. Next catch carries corrected stack/code dumps; if those name
|
|
a return chain, the routine falls out. Rate note: 4 faults in the last 10
|
|
vRIO-live runs -- hovering near the 25-40%% band, still load-window-only.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE FAULTING INSTRUCTION, DECODED: A HANDLER-CHAIN WALK WITH A BAD NODE
|
|
--------------------------------------------------------------------------------
|
|
The SegPhys-corrected dump finally read real code (the earlier zeros were the
|
|
probe reading EIP as a bare linear address -- CS=00FF has a base). At the
|
|
fault:
|
|
|
|
66CF ... <- loop top
|
|
66D4 push 0
|
|
66D6 push edi ; 00017574
|
|
66D7 push ebx ; F000CA60 <- the node
|
|
66D8 push esi ; 00017640
|
|
66D9 call dword near [ebx+4] <- FAULTS
|
|
66DC cmp eax,0
|
|
66DF jz 6E81 ; a handler claimed it -> done
|
|
66E5 mov ebx,[ebx] ; next node
|
|
66E7 jmp 66CF ; loop
|
|
|
|
That is a LINKED-LIST WALK with a per-node CALLBACK: node = {next @ +0,
|
|
handler @ +4}, four args pushed, "handled" if the handler returns nonzero.
|
|
Textbook DPMI exception/interrupt handler chain.
|
|
|
|
The arithmetic closes exactly: the read is DS_base + ebx + 4, and
|
|
0x7000FA64 - 0xF000CA64 = 0x80003000, so DS has base 0x80003000 and the
|
|
famous cr2 is just that wrap. (My earlier "[EBX+0x3004]" reading was
|
|
fabricated from the cr2 alone -- there is no 0x3004 displacement; the
|
|
instruction is a plain +4. That is why the constant appeared in no binary.)
|
|
|
|
So the defect is: a chain node's NEXT pointer holds 0xF000CA60, the value
|
|
DOSBox-X (and a real BIOS, comparably) parks in unhooked IVT slots -- the
|
|
walk expected a terminator and got an interrupt vector. The host then reads
|
|
that value's +4 as a function pointer and dies. Every catch has identical
|
|
registers (EAX=1 EBX=F000CA60 ECX=0 EDX=FFFFFFFF, same ESP/EBP), so this is
|
|
one deterministic path, entered often enough that a 60-second load window
|
|
catches it ~25-40%% of the time and a 15-second one (shipped) mostly does not.
|
|
|
|
Still open: WHICH routine owns the chain and where the bogus next came from.
|
|
The probe now walks the EBP frame chain to name the caller; faulthunt.sh is
|
|
looping for the next catch.
|
|
|
|
--------------------------------------------------------------------------------
|
|
A HYPOTHESIS THAT DIED, AND WHAT IT REVEALED
|
|
--------------------------------------------------------------------------------
|
|
Hypothesis: our diagnostics inflate the load window (BT_* logging to COM3 is
|
|
known to cost 3x), the window IS the fault exposure, so a quiet run should
|
|
load faster and fault less. Both halves were wrong.
|
|
|
|
pod_render_quiet (only the two BT_FORCE_* vars, no logging):
|
|
run1 FAULT 159s run2 FAULT 151s run3 FAULT 275s 3/3
|
|
|
|
Same signature every time. The conf differs from pod_render_rec ONLY by the
|
|
six BT_* logging vars -- so removing our per-frame logging made the fault
|
|
rate go from ~25-40%% to 100%%. Logging SUPPRESSES it.
|
|
|
|
That is the strongest evidence yet that the residual is a TIMING-SENSITIVE
|
|
HOST BUG, not anything about our data. Writing to COM3 changes when
|
|
interrupts land relative to the DPMI host's chain walk; the walk is one
|
|
deterministic path (identical registers every catch) that is only sometimes
|
|
entered with a bad node. Nothing about our attribute tables, our renderables
|
|
or our load volume can plausibly be modulated by whether we print to a serial
|
|
port -- interrupt phase can.
|
|
|
|
Practical consequences, both immediate:
|
|
* pod_render_rec (the logged conf) is the LESS fault-prone rig, not the
|
|
more. Use it, not the quiet one, until this is fixed.
|
|
* The "shipped survives" baseline weakens further: shipped writes plenty to
|
|
COM3 too, so it sits on the suppressed side of this same effect as well
|
|
as having a shorter window.
|
|
|
|
The wall-clock numbers also correct an earlier assumption: 150-275s
|
|
boot+load, not ~60s. The ~60s figure was the LOAD DRAIN measured in game
|
|
ticks; DOS boot, the two diagnose passes and card init dominate the rest.
|
|
Shipped's advantage is therefore in the drain phase specifically, which is
|
|
still worth understanding, but it is a smaller slice of the run than assumed.
|
|
|
|
NEXT (emulator-side, and now clearly the right side): the chain walk at
|
|
0x66CF-0x66E7 is entered with a node whose next is an unhooked IVT value.
|
|
Naming the owning routine needs DPMI32VM reversing -- the host keeps no EBP
|
|
frames (the frame walk yielded one bogus entry), so the route in is a
|
|
breakpoint-style trace: log every entry to 0x66CF with the head node, and
|
|
diff a clean run against a faulting one.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE COCKPIT, FROM THE PILOT'S SEAT
|
|
--------------------------------------------------------------------------------
|
|
emulator/render-bridge/cockpit_max.png -- the MAX_COP canopy seen from the
|
|
eyepoint, running on the live pod: window frames, A-pillars, the sill across
|
|
the bottom of the view, and the arena city out through the glass at 30fps.
|
|
Log line for the same run:
|
|
|
|
[skl] video\max.skl -> 26 nodes, 1 objects, 1 eye
|
|
|
|
Exactly the shape the design predicted: the X-variant carries the SAME 26-node
|
|
chain as the body skeleton (so the eye composition and torso twist are
|
|
identical) with ONE object -- the canopy shell -- instead of nineteen body
|
|
parts. The eye sits on jointeye inside it.
|
|
|
|
This closes the arc that started with a mech statue at the world origin and
|
|
a camera frozen beside it: entity -> RootRenderable -> skeleton under its DCS
|
|
-> cockpit variant for the inside view -> eye on the canopy joint. What a
|
|
pilot saw in 1996, rendered by our reconstruction through the emulated
|
|
Division board.
|
|
|
|
The cage machinery in COCKPIT-CAGE-NOTES.md (punch-texel cutouts, glance-hide,
|
|
twist conjugation) now has its subject: this canopy is the a11/MAX_COP shell
|
|
those notes were written about, and it arrives through the authentic path
|
|
rather than the bridge's own fixture detection.
|
|
|
|
--------------------------------------------------------------------------------
|
|
ANOTHER HYPOTHESIS DOWN: THE OVERRUN DRAIN LOOP IS NOT THE TRIGGER
|
|
--------------------------------------------------------------------------------
|
|
serialnamedpipe's P_RX_BLOCKED path drains its whole backlog when the guest
|
|
has not read in time -- `while (doReceive());`, copied from directserial.
|
|
That looked like the trigger: a named pipe's backlog is unbounded where a
|
|
real port's is capped by the line rate, so the loop could teleport a burst
|
|
into the UART in zero guest-time. Bounded it to one byte (real UART overrun
|
|
semantics) and re-ran the conf that had faulted 3/3.
|
|
|
|
pod_render_quiet, bounded drain: FAULT 283s, FAULT 226s, FAULT 204s
|
|
|
|
3/3 again. Killed. And the measurement that explains WHY it was never
|
|
plausible: overruns DURING a run are 1-3 per report period, because vRIO
|
|
sends about one byte every 1-3ms -- the backlog is shallow and the loop
|
|
never had anything to teleport. (The 494/499 counts in the logs all land
|
|
AFTER the game exits.) I read the code and inferred a burst without first
|
|
measuring the queue depth the code operates on.
|
|
|
|
The drain-all DEFAULT IS RESTORED -- it is the validated directserial
|
|
behaviour the RIO's rxpollus/rxburst tuning was calibrated against, and
|
|
changing it on a dead hypothesis would risk real-cockpit timing for nothing.
|
|
VPX_RX_OVERRUN_ONE=1 opts into the bounded form if a later experiment wants
|
|
it.
|
|
|
|
FAULT LEDGER (what is dead, so nobody re-runs these):
|
|
* the skeleton walk (predates it; env-gated A/B)
|
|
* ESP drift / stack size (probe: drift=0; PE geometry equal)
|
|
* priority-0 starvation / deadlock ([queues] + nextReady)
|
|
* EulerAngles::operator= (probe never fired; address is host)
|
|
* the VPX ack heuristic (reproduces after the fix)
|
|
* our debug logging (removing it makes it WORSE)
|
|
* the RIO test-mode handshake (quiet run's init passed, faulted)
|
|
* the overrun drain loop (this entry)
|
|
* Torso attribute truncation -- NOT dead: real, and took the
|
|
rate from 100%% to ~25-40%%
|
|
|
|
WHAT SURVIVES: a deterministic DPMI-host code path (identical registers every
|
|
catch) that walks a {next,handler} chain and meets a node whose pointer is an
|
|
unhooked IVT value; entered under RIO interrupt load; probability modulated by
|
|
anything that shifts guest timing. The only remaining route in is naming the
|
|
routine that owns that chain, which needs a trace of entries to host 0x66CF
|
|
with the head node -- clean run vs faulting run. That is a DPMI32VM reversing
|
|
sub-project, and it is now decoupled from the reconstruction: with vRIO down
|
|
the pod is 100%% reliable, and with the logged conf it is ~70%%.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE TORSO TWISTS FROM THE BUTTON PATH -- AND TWO BUFFER OVERRUNS FOUND
|
|
--------------------------------------------------------------------------------
|
|
BT_FORCE_TORSO sweeps the twist BUTTONS (the members the streamed direct
|
|
mappings write) so the whole chain can be exercised without a hand on the
|
|
RIO. Live on the pod:
|
|
|
|
[torso] twist=-0.0148 cmdL=0 cmdR=1 rate=0.8727 limits=(-2.4435..2.4435) joint=1
|
|
[torso] twist=-0.8910 cmdL=0 cmdR=1 ...
|
|
[torso] twist=-0.8308 cmdL=1 cmdR=0 ...
|
|
[torso] twist=-0.0157 cmdL=0 cmdR=1 ...
|
|
|
|
Sweeping and reversing at the resource rate (0.873 rad/s = 50 deg/s), inside
|
|
the authored limits, with jointtorso resolved (type=1, enabled). End to end:
|
|
attribute id -> direct-mapping write destination -> TorsoSimulation
|
|
integrator -> skeleton joint -> the canopy eye that hangs off that chain.
|
|
The 5.3.68 table fix and the 5.3.71 integrator are both confirmed working
|
|
against real button semantics.
|
|
|
|
TWO BUFFER OVERRUNS, one of them mine. Reading TORSO.CPP back after the edit
|
|
caught the ctor still zeroing dynamicsState[22] after I had shrunk the array
|
|
to [16] by carving the command members out of its front -- 24 bytes written
|
|
past the end of every Torso, on the heap, every mission, since 5.3.68. It
|
|
compiled and ran; C++ does not check. A sweep for the same shape
|
|
(literal-bound loop vs declared size, across BT/BT_L4/MUNGA) then found a
|
|
PRE-EXISTING one: MECHMPPR.CPP zeroing reserved[22] against reserved[21].
|
|
|
|
Both now use ELEMENTS(), never a literal, with the reason in the comment.
|
|
The rule for this tree: any array carved out of a reserve block must have its
|
|
initialiser bound by ELEMENTS -- the carve is exactly the edit that silently
|
|
invalidates a literal.
|
|
|
|
(Neither explains the residual DPMI-host fault: it predates 5.3.68 at 3/3,
|
|
and the mapper one is four bytes into adjacent heap. But both were real
|
|
corruption running in every mission, and the Torso one was introduced by the
|
|
very fix that cut the fault rate -- worth remembering when a rate MOVES
|
|
instead of going to zero.)
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE TWIST IS LIVE IN THE SIM AND ABSENT FROM THE WIRE -- JOINT ARTICULATION IS
|
|
THE NEXT BRICK
|
|
--------------------------------------------------------------------------------
|
|
pod_render_twist.conf isolates it: throttle and turn forced to zero, torso
|
|
sweeping. [sim] confirms spd=0, so the only thing that can move the view is
|
|
the torso joint the cockpit eye hangs off. Five frames captured across a
|
|
full sweep:
|
|
|
|
tt1 vs tt2..tt5: ~1370 differing pixels (0.3%%)
|
|
tt2..tt5 vs each other: 0-74 pixels (0.0%%)
|
|
|
|
The view does not move. Meanwhile the same run's telemetry has the twist
|
|
sweeping the full authored range (-0.895 -> -0.022 rad, cmdL/cmdR
|
|
alternating, joint resolved), and the bridge reports:
|
|
|
|
anim_abs=1 joints=0
|
|
|
|
Only the ROOT articulates. That is the whole story: RecurseSKLFile builds
|
|
each skeleton node's DCS with a baked matrix and flushes it ONCE at build
|
|
time, and nothing re-flushes a node when its joint angle changes.
|
|
Torso::TorsoSimulation faithfully calls horizontalJointNode->SetRotation()
|
|
every frame -- updating the Joint OBJECT -- but no renderable reads that back
|
|
into the node's dpl_DCS and re-flushes it. So the simulation twists and the
|
|
board never hears about it.
|
|
|
|
THE MISSING PIECE is the per-frame JOINT RENDERABLE: the engine's
|
|
ChildOffsetRenderable family (L4VIDRND.CPP:930) exists exactly for this --
|
|
each carries its own offset DCS under a parent joint's DCS, and its Execute
|
|
re-flushes when the joint moves, which is what fills vr_flush_dcs_artic
|
|
(0x1f) with more than one node. Our RootRenderable already proves the
|
|
pattern end to end for the hull; the skeleton nodes need the same treatment
|
|
per articulated joint.
|
|
|
|
That single brick unlocks three things at once, which is why it is the right
|
|
next one: the torso twist (and with it the cockpit eye panning, since the eye
|
|
hangs off that chain), the LEG GAIT (the 12-joint flurry the real-pod capture
|
|
shows while walking), and weapon-pod aim. Until then the mech translates
|
|
through the world as a rigid body -- which is exactly what every frame so far
|
|
has shown.
|
|
|
|
--------------------------------------------------------------------------------
|
|
JOINT ARTICULATION IS LIVE -- 22 NODES, AND THE TWIST REACHES THE BOARD
|
|
--------------------------------------------------------------------------------
|
|
RecurseSKLFile now builds a JOINT RENDERABLE for any node whose page name
|
|
resolves to a live skeleton Joint, instead of a static baked DCS:
|
|
|
|
HingeX/Y/Z -> HingeRenderable (watches Joint::GetHinge)
|
|
Ball -> BallJointRenderable (watches Joint::GetEulerAngles)
|
|
otherwise -> the static path, unchanged
|
|
|
|
Each holds the node's rest offset in one DCS and the live rotation in a child
|
|
DCS, and its Execute compares the watched value against a cached copy and
|
|
calls DPL_FLUSH_DCS -- the engine's own mechanism (L4VIDRND.CPP:1026+). The
|
|
joint's value comes from the mech's JointSubsystem via Mech::ResolveJoint, so
|
|
the simulation and the renderer read ONE source. Env-gated on BT_JOINTS
|
|
while it proves out; the static path is a working cockpit render.
|
|
|
|
[skl] video\max.skl -> 26 nodes, 1 objects, 1 eye, 22 articulated
|
|
|
|
and the bridge, which reported `anim_abs=1 joints=0 twist=+0.00` before, now
|
|
reports `joints=1 twist=-0.86` -- matching the game's own
|
|
[torso] twist=-0.856 to two decimals. The board is hearing the joint move.
|
|
|
|
VISUALLY: with the mech stationary, frames that differed by 0.0%% now differ
|
|
by 62-80%%. The view swings with the torso. That is the brick landing.
|
|
|
|
A CRASH IT EXPOSED, worth keeping: Mech::ResolveJoint passed
|
|
segment->GetJointIndex() straight to GetJoint with NO validity check, and a
|
|
segment that HAS no joint reports -1 -- TableIterator::GetNthImplementation
|
|
then indexes [base + -1*4] and dies (guest 00426A1D, ECX=FFFFFFFF). Torso
|
|
never hit it because it only ever asks for its own authored twist-joint name;
|
|
the skeleton walk asks for every page, and most .SKL pages are sites or
|
|
static segments. Now bounds-checked against GetJointCount.
|
|
|
|
OPEN, and the next thing to chase: the canopy does not stay rigid in the
|
|
view. Both the canopy (Object on jointtorso) and the eye (site on jointeye,
|
|
whose parent is jointtorso) hang off the SAME articulated node, so they
|
|
should move together and the world alone should pan. Instead the canopy
|
|
silhouette varies 144k-207k px across a sweep. Cancelling the bridge's own
|
|
cage compensation (CAGE_TWIST_SIGN=0 -- it exists because the SHIPPED game
|
|
links the cage under the vehicle root and never touches it, which is no
|
|
longer true of ours) did not close it.
|
|
|
|
Leading hypothesis: the eye is not actually driven by our DCS chain at all.
|
|
DPLRenderer::SetupCull builds worldToEyeMatrix from
|
|
GetSegmentToWorld(siteeyepoint) -- the SIMULATION's segment transform --
|
|
independent of the render tree. So the canopy follows our render chain while
|
|
the eye follows the sim's segment chain, and they diverge exactly like this
|
|
whenever one carries the twist and the other does not. Test: log both
|
|
per frame and compare; if that is it, the fix is making the segment chain and
|
|
the render chain agree about which node owns the twist.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE CANOPY QUESTION, ANSWERED: OUR WIRE IS RIGHT, THE BRIDGE CANNOT DECODE IT
|
|
--------------------------------------------------------------------------------
|
|
Decoding the fifodump from the articulated run settles it, and the answer is
|
|
not in our code at all.
|
|
|
|
0x1f records this run: 26133 (was ~200 before articulation)
|
|
every one: n=1, payload 20 bytes = [action][count][handle][2 floats]
|
|
sample: handle 0x684, sine=-0.04536 cosine=0.99897 -> -2.60 deg
|
|
handle 0x684, sine=-0.13226 cosine=0.99122 -> -7.60 deg
|
|
|
|
That is the torso twist, sweeping, authentically encoded. Our HingeRenderable
|
|
path emits exactly what the 1995 engine emits: dpl_SetDCSYAxis(dcs, sin, cos)
|
|
followed by a batched articulation flush.
|
|
|
|
The bridge cannot use it. vrboard's 0x1f parser accepts record widths of 12,
|
|
2 and 5 floats but only APPLIES the 12-float form, with the comment "joint
|
|
sin/cos records: axis semantics unknown -- the flushed matrix stands (mech
|
|
limbs won't articulate yet)". So the canopy's DCS never rotates in the
|
|
bridge's scene, while the bridge's own camera machinery picks the twist up
|
|
by another route -- and the two diverge, which is exactly the artefact.
|
|
(anim_abs reading 0 in the same run is consistent and innocent: the root only
|
|
re-flushes when localToWorld changes, and this mech is deliberately
|
|
stationary.)
|
|
|
|
WHY THE AXIS IS MISSING, and it is not an oversight in the decoder:
|
|
dpl_SetDCSXAxis / YAxis / ZAxis all take (dcs, sin, cos) and all produce the
|
|
SAME 12-byte record (DPL_VPX.H:23-25). The axis is not on the wire; the
|
|
board must carry it per node from the DCS's own state. A decoder that only
|
|
ever sees the stream genuinely cannot recover it.
|
|
|
|
TWO WAYS FORWARD, both legitimate:
|
|
1. Tell the bridge the axis out of band. It already parses the .SKL family
|
|
for other purposes, and Type=hingex/hingey/hingez names the axis per
|
|
node -- a handle->axis map built at scene-build time closes it.
|
|
2. Use the engine's OTHER hinge path. HingeRenderable::Execute is written
|
|
both ways behind `#if SINGLE_AXIS_HINGE` (L4VIDRND.CPP:1044): the #else
|
|
branch converts the Hinge to a Quaternion, writes the FULL matrix and
|
|
flushes -- producing the 12-float records the bridge already applies.
|
|
Both are the shipped engine's own code; the flag is a compile-time
|
|
choice, not a reconstruction decision.
|
|
|
|
Route 1 is the faithful one (it keeps our wire byte-identical to the 1995
|
|
board's) and route 2 is the quick one. Route 1 also fixes the LEG GAIT for
|
|
free, since every limb hinge has the same problem.
|
|
|
|
So the reconstruction's articulation work is DONE and correct as of 5.3.80;
|
|
what remains is a decoder gap in the viewing tool, which is a different
|
|
component with a different owner.
|
|
|
|
--------------------------------------------------------------------------------
|
|
THE MFD BLEED (operator report): MECHANISM FOUND -- AN UNINITIALISED TRANSLATION
|
|
TABLE INDEXED BY 8-BIT ART ON A 6-BIT PORT
|
|
--------------------------------------------------------------------------------
|
|
Operator, 2026-07-29: artifacts bleed from the radar/colour display into the
|
|
MFDs -- present in the BT411 builds too, ABSENT in the original. That last
|
|
clause is the key: whatever it is, both reconstructions share it and the
|
|
shipped game does not. This is almost certainly the same "intermittent
|
|
plane-write artifact" that has survived seven earlier hypotheses in this file.
|
|
|
|
THE CHAIN, every link read from the code:
|
|
|
|
1. L4GAUGE.CFG:4395 configure(0,sec,270,0x003F,clut0,rgb,btspal.pcc)
|
|
The colour head is SIX BITS. The MFDs are bits 8-15 of the SAME 16-bit
|
|
words (0x0100..0x8000), which is why a colour-head defect can only ever
|
|
show up as MFD garbage -- one framebuffer, ten plane-packed ports.
|
|
|
|
2. L4VB16.CPP:5419 L4GraphicsPort::BuildSecondaryTranslation()
|
|
BitWrangler wrangler(bitMask & 0xFF, 8);
|
|
do { *destination++ = wrangler.Value; } while (wrangler.IncrementActive());
|
|
For mask 0x3F this writes exactly 64 entries of `int translationTable[256]`
|
|
(L4VB16.HPP:487). Entries 64..255 are NEVER INITIALISED.
|
|
|
|
3. L4VB16.CPP:2708 DrawPixelMap8
|
|
color = (Word) translation_table[source_data];
|
|
An 8-BIT source pixel indexes that table directly. No clamp, no mask.
|
|
|
|
4. L4VB16.CPP:637 DrawPoint / Replace
|
|
bitmask = ~bitmask;
|
|
*dest = (Word)((*dest & bitmask) | color);
|
|
The colour is OR'd in UNMASKED. The writer's contract is "caller passes a
|
|
colour already confined to this port's bits" -- which holds for entries
|
|
0..63 and is violated by whatever garbage sits at 64..255.
|
|
|
|
5. The art supplies the out-of-range indices. Decoding the sec port's own
|
|
bitmaps (the CFG scopes widgets with `port = <name>;`):
|
|
AVACRIT.PCC 172x217 94%% of pixels <= 63, 6%% at exactly 231
|
|
BTSEC1.PCX 480x640 99.8%% <= 63, 0.2%% at exactly 254
|
|
ADPAL.PCC 2x5 all 10 pixels = 99 (a palette STRIP, not art)
|
|
The high values are SENTINELS -- transparency keys and ramp markers --
|
|
not general colour data. Drawn transparent they are skipped and never
|
|
touch the table; drawn OPAQUE they index the uninitialised tail.
|
|
|
|
WHY THE ORIGINAL IS CLEAN. translationTable lives in a heap-allocated
|
|
L4GraphicsPort. On the pod the allocation was almost certainly fresh zeroed
|
|
memory, so entries 64..255 read 0 -> colour 0 -> black -> invisible against a
|
|
black head. Our build (and BT411's) has different heap history, so the same
|
|
reads return junk with bits in the MFD planes. That also explains the
|
|
intermittency that defeated the earlier hypotheses: the artifact depends on
|
|
heap CONTENTS, not on any drawing logic, which is exactly why it never
|
|
correlated with anything in the widget code.
|
|
|
|
SUSPECTS, narrowed to three. Only the OPAQUE path can push a sentinel through
|
|
the table; our reconstruction has exactly three such call sites:
|
|
BTL4GAU2.CPP:916 localView.DrawBitMapOpaque(0, 0, base)
|
|
BTL4GAU2.CPP:1159 view->DrawBitMapOpaque(0, 0, bmp)
|
|
BTL4GAUG.CPP:1953 localView.DrawBitMapOpaque(foregroundColor, 0, bit_map, ...)
|
|
Next step is to check DrawBitMapOpaque's semantics -- whether it SUBSTITUTES
|
|
the background colour for key pixels (safe) or maps every pixel through the
|
|
table (the bug) -- and then which of the three draws sec-port art containing
|
|
231/254.
|
|
|
|
THE FIX, when it comes, goes in OUR tree, not the archive. L4GREND.CPP:543
|
|
constructs the ports, and translationTable/BuildSecondaryTranslation are
|
|
protected, so a subclass cannot be slipped in from outside. The established
|
|
mechanism is the build's source410-shadows-CODE rule: bring the TU into
|
|
restoration/source410/MUNGA_L4/ and zero the tail there
|
|
(`for (i = 1 << numberOfBits; i < 256; ++i) translationTable[i] = 0;`), which
|
|
makes our behaviour deterministic AND matches what the original actually did
|
|
at runtime. Worth doing regardless of which call site is guilty -- it turns a
|
|
heap-dependent artifact into a black pixel.
|