The lab went from one Windows guest to five and there was no way to see what
they were all doing without issuing a QEMU screendump per guest by hand.
- tools/vmwatch.py: always-on HTTP service serving an auto-refreshing wall of
live guest screens. Runs on spicy as vmwatch.service; browse it at
http://192.168.3.201:8140/. Click a tile for that guest full size. Guests are
discovered from /etc/pve/qemu-server by matching sots-re, so clones appear and
vanish on their own. A stopped, paused or unreachable guest gets a labelled
placeholder tile carrying the monitor's own error, never a broken image or a
500. Python 3 stdlib only.
- tools/vmwatch-install.sh: install/update/uninstall the unit on the host.
- tools/vmshot.py: one-shot contact sheet, and --one <id> for a full-size grab.
Pulls frames from the vmwatch service when it is up (0.5s) and falls back to
ssh + qm monitor when it is not (4s).
- guides/lab-screen-wall.md: how to use both, and why.
Capture goes over each guest's QMP socket rather than forking qm: qm is a Perl
program, and one fork per guest per tick cost ~90% of a host core and a 728 MB
cgroup peak. Direct QMP is 0.33 CPU-seconds per 88s and 23 MB RSS. QEMU 11 here
dumps PNG natively; the fallback PPM encoder was verified pixel-identical to
QEMU's own on a real framebuffer.
Read-only throughout: screendump does not perturb the guest (method-rule 19),
so reading VM 140's screen is not an experiment and does not take its lock.
Settles the 0x18-vs-0x1c contradiction lane X raised. 0x1c is right, everywhere,
and there is exactly one std::string instantiation in this binary:
_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18.
ObservedTech+0x24 is that string's trailing empty-allocator word, not the
unaccounted data field it was read as. Three complete enumerations of the element
each skip it: ObservedTech::Write 0x00817cf0, the ctor 0x008562a0, and the copy
ctor inlined at 0x0079a184. Generalised with a new scanner, tools/strfootprint.py,
which recovers every (base, disp, tag) handed to the Mars::Stream string helpers:
65 std::string members off a non-stack base across every serializer in the exe,
ZERO with a sibling member inside the 0x1c span, and 51 of the 52 measurable
inter-member gaps exactly 0x1c. Corroborated by the vector<string> walk stride
(add esi,0x1c @0x00699c29), PostEvent's by-value strings at [ebp+8]/[ebp+0x24]
with RET 0x4c, and MoraleEvent 0x50 = name@0x34 + 0x1c.
Blast radius: zero recovered struct tables were wrong. Every string-bearing layout
already used 0x1c spans and 0x1c gaps -- ServerPlayer::pswd @0x2dc..0x2f7, the row
flagged for re-checking, included. Only prose carried the 0x18 number: the
loader-prototypes conventions line, the GlobalConst_ParseString prototype, and the
ObservedTech element table. struct-recovery S0 additionally had _Mysize/_Myres
transposed (size@0x14, res@0x18) while every table in the same file used the
correct offsets; fixed.
ObservedTech's four on-disk fields are now mapped rather than guessed, by reading
the serializer as lane X suggested: +0x04 uint16 otnF, +0x06 uint16 otnL, +0x08
bool odet (ONE BYTE, WriteBool), +0x0c std::string otch (0x1c), +0x28 int owith
= 0x2c exactly. That matches save_reader.py's on-disk order already. Game::
ObservedWeapon (0x00817bc0/0x00817b10) is the same element with tag owep.
Oracles unaffected and re-run: save_reader 36/36 and --strict exit 0 on all three
real saves; state_checksum 38 tests OK, coverage PROVED byte-for-byte on turn1 and
turn3. sots-engine wip/strings 32d3e36 syncs the header and corrects two stale
"unpinned" comments: clean_room_check OK, host ctest 33/33.
Standing rule this produced: never size a struct member from the offsets the code
touches. This build's STL puts the empty allocator LAST in both string (0x1c) and
vector (0x10), and an empty allocator is never loaded or stored, so a touch-scan
undercounts by exactly 4 every time. Size from an enumeration instead.