Commit graph

4798 commits

Author SHA1 Message Date
Erik
a3b4a8e2e5 fix(vt): H test-restoration + disabled-rule refusal + double-side gate
Item H (slice-1 fix round), six sub-parts:

1. Restored VtankMetaProfileSerializerTests.LoadsKnownTypedCondActRecord
   and SignedHighBitLandblockIdRoundTripsExactly (the latter adapted from
   the deleted RoundTripPreservesEveryVtankConditionActionAndEmbeddedNav's
   LandblockEquals/LandcellEquals coverage of unchecked((int)0x8B370000u)),
   both deleted as collateral damage of an unrelated file move in commit
   0d10399e0. Neither calls the deleted VtankMetaProfileSerializer.Save
   writer (demoted to import-only in 3ff9461ef) — both are pure reader
   assertions.

2. MetafSerializer.SaveMeta now refuses (throws InvalidOperationException
   naming the count) to silently drop a disabled MetaRule: real VTank/
   metaf has zero concept of "disabled" (confirmed: metaf_monolithic.py
   has no "enabled"/"disabled" occurrences anywhere), so
   MetaRule.Enabled is a MossTank-only extension with no metaf-compatible
   marker. A new SaveMeta(profile, dropDisabledRules: true) overload lets
   a caller accept the loss explicitly.
   MossTankMetaProfileStore.WriteLegacyExport (the .af convenience mirror
   beside MossTank's own fully-fidelity JSON storage) deliberately does
   NOT opt in — it leaves that mirror stale and logs a warning via its
   existing try/catch rather than losing the rule. Recorded as gap 6 in
   docs/research/vtank-kb/07-meta-and-expressions.md section 5.

3. New VtankProfilesDefault (src/AcDream.App/Plugins/): the graphical
   host's default VtankProfiles root (<DataDirectory>/vtank), extracted
   out of Program.cs's inline Path.Combine call into its own pure,
   injectable-root function specifically so the "Path.Combine only, never
   a hard-coded Windows path" guarantee is a real, failable Linux-path
   unit test (VtankProfilesDefaultTests.ResolveIsBuiltWithPathCombineOnly)
   rather than something only checkable by reading the source — the
   pattern item F's VtankProfileDirectory rewrite removed when
   Resolve/PortableDefault moved out of the plugin.

4. New BuffedDoubleRequirementDoesNotApplyBonusWhenBaseKeyIsAbsent: the
   KeyExistsDouble gate (ComputedItemInfo.cs:234) already existed in
   BuffedDouble, but only the int side
   (BuffedIntRequirementDoesNotApplyBonusWhenBaseKeyIsAbsent) had a
   pinning test.

5/6. DoubleSpellBonuses gained an explicit Change field (KB doc 05
   section 2.2: "additive unless the static table's Change==1, in which
   case multiplicative", ComputedItemInfo.cs:244), replacing
   BuffedDouble's prior `(int)bonus.Bonus == 1` magnitude-based proxy —
   that proxy only worked because every multiplicative bonus in the
   current 19 rows happens to fall in [1.0, 2.0) and every additive one
   happens to be under 1.0; it would have silently mis-branched on a
   future row like an additive 1.5 or a multiplicative 2.0+. Every
   existing row's Change value was derived mechanically from its old
   proxy result (no behavior change for the current table), and
   BuffedDoubleRequirementAppliesAdditiveBonusWhenBaseKeyExists/
   AppliesMultiplicativeBonusWhenChangeIsSet pin both branches through
   the real named field.

Full MossTank suite: 574 -> 581. App.Tests
(Plugin|LaunchOptions|RuntimeOptions filter): 135 -> 137. Core.Tests
(Plugin filter): 50/50 (no change, no Core-side edits this item).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:20:20 +02:00
Erik
e53507396f fix(vt): G real mineOnly predicate, .cdf character binding, .ast naming
Item G (slice-1 fix round).

- ListSettingsProfiles's "Mine only" filter previously hid EVERY shared
  (non-sub-profile) file when checked. Real VTank's predicate
  (uTank2/PluginCore.cs:7020-7024, cSettingsShowAll/field a9) is
  "!checked || file == current" — a shared file is hidden only when the
  box is checked AND it is not the file currently assigned to the
  character, so the profile actually in use never disappears out from
  under the user just because they ticked the box. New optional
  currentFileName parameter carries that exemption.
- New VtankProfileDirectory.CdfFileName/TryReadCharacterBinding: the
  real per-character binding file (da class, da.cs:15,105-164) —
  filename Server_CharacterName.cdf (Server-then-Name order), literal
  "uTank2 CDF 1.0" version-header line 1 (a mismatch is treated
  identically to a missing file, per da.cs:113-121, and the method
  returns null in both cases rather than fabricating a default binding),
  lines 2-4 the settings/loot/nav filenames currently assigned to that
  character, optional line 5 the meta filename (present only when the
  stream wasn't already at EOF — an older .cdf predating meta support
  has no line 5 at all). The legacy .uts->.usd settings-filename
  rewrite (da.cs:130-141) is applied here so callers never see a stale
  extension.
- New VtankProfileDirectory.AstFileName: the per-character spell-
  tracking cache name (dm class, dm.cs:391) — CharacterName_Server.ast,
  no "--" prefix, not user-selectable. Note the concatenation order is
  the REVERSE of CdfFileName's (Name-then-Server vs Server-then-Name) —
  both are pinned by dedicated tests so a future edit can't silently
  swap one for the other.
- New RealAstFixturesParseAsTheSpellsTable theory over the three
  committed owner-{a,b,c}.ast fixtures: parses via the existing
  VtankDatabase.Parse (same "y" grammar as .usd) and asserts the real
  Spells table's four columns (SpellID/EndTime/Target/CastTime), per
  live inspection of +Horan_sawato.ast recorded in
  docs/research/vtank-kb/01-settings-and-profiles.md section 3.

No production caller of these new members exists yet — same as item F,
this is contract/rule-implementation work per A2's "foundation only, not
yet wired into the profile stores" scope; wiring belongs to the store
cutover explicitly deferred to round 2.

Full MossTank suite: 562 -> 574 (12 new tests, no regressions). App.Tests
(Plugin|LaunchOptions filter): 82/82.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:07:11 +02:00
Erik
2040f2bcfb fix(vt): F host-composed VtankProfiles storage replaces raw path string
Item F (slice-1 fix round). IPluginHost.VtankProfileDirectory handed the
plugin a raw string path and told it to fall back to its own
System.IO-based portable default when null — a plugin reading and
resolving filesystem paths itself, which is exactly the seam the rest of
IPluginHost.Storage deliberately avoids (Core.Plugins.ScopedPluginHost
scopes/validates every key; the plugin never sees a path).

- IPluginHost: VtankProfileDirectory (string?) deleted; new VtankProfiles
  (IPluginStorage, defaults to NoOpPluginStorage) added — a second,
  UNSCOPED storage instance (unlike Storage, which Core scopes per
  plugin manifest id) rooted at a host-composed VTank-compatible
  directory.
- ScopedPluginHost.VtankProfiles forwards _inner.VtankProfiles directly
  (no scoping — it names one shared external location, not per-plugin
  data). New PluginSessionTests.ScopedHostForwardsVtankProfilesUnscoped
  proves the forwarded instance is the exact same object (Assert.Same),
  not a wrapper.
- AppPluginHost/Program.cs: new vtankProfiles constructor parameter,
  composed as FilePluginStorage(runtimeOptions.VtankProfileDirectoryOverride
  ?? Path.Combine(applicationPaths.DataDirectory, "vtank")).
- RuntimeOptions.VtankProfileDirectoryOverride: new init-only property
  parsed from ACDREAM_VTANK_PROFILE_DIR (row added to
  docs/launch-options.md, side-effects column states the redirect is the
  only effect and documents the NullIfEmpty whitespace-not-special-cased
  quirk it shares with every other path-override flag). New
  RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet.
- FilePluginStorage.List(prefix): empty prefix now means "the storage
  root itself" instead of throwing (Resolve() rejects empty/whitespace
  keys, which is correct for every OTHER caller but wrong for "list
  everything" — VtankProfileDirectory needs exactly that).
- Headless: HeadlessPluginHost gained the same VtankProfiles
  property/constructor param, threaded through HeadlessPluginSession.Create
  -> HeadlessSessionHost -> HeadlessProcessHost, composed from the new
  HeadlessPathSet.VtankProfilesDirectory (<DataDirectory>/vtank, no
  ACDREAM_VTANK_PROFILE_DIR-equivalent override — Headless path overrides
  are HeadlessPathOverrides/CLI flags, not env vars). A small
  AcDream.Headless.Plugins.FilePluginStorage duplicates the App
  implementation byte-for-byte (Headless does not reference AcDream.App
  and no shared "platform plugins" library exists yet to host one copy;
  documented as a reasonable future consolidation, not required here).
- VtankProfileDirectory.cs rewritten: Resolve/PortableDefault deleted
  outright (no more System.IO, no plugin-owned portable-default fallback);
  ListSettingsProfiles/ListNavigationProfiles/ListMetaProfiles now take
  IPluginStorage and enumerate through EnumerateFileNames, which calls
  storage.List(string.Empty) and skips any key containing '/' (VTank's
  profile directory is flat; a nested key from some other IPluginStorage
  implementation is not a profile file). VtankProfileDirectoryTests
  rewritten against an in-memory IPluginStorage fake instead of real
  temp directories; new NestedPathKeysAreNotTreatedAsProfileFiles pins
  that skip. The prior Resolve/PortableDefault-specific tests (Linux-path
  guarantee, host-override-vs-portable-default) are superseded by
  RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet plus
  the RuntimeOptions.FromEnvironment Path.Combine-only composition in
  Program.cs.
- docs/architecture/acdream-architecture.md: one sentence in the
  Storage/List(prefix) paragraph naming VtankProfiles as the second,
  unscoped storage.

No production caller of VtankProfileDirectory's listing methods exists
yet (A2's foundation is not wired into MossTankProfileStore/
MossTankMetaProfileStore/MossTankRouteProfileStore's own selection —
per that slice's own ledger note), so this is a contract + plumbing
change with no MossTank runtime behavior change.

MossTank suite: 562/562. Core.Tests (Plugin filter): 50/50. App.Tests
(Plugin|LaunchOptions|RuntimeOptions filter): 135/135. Headless.Tests:
173/174 (the one failure, HeadlessCredentialResolverTests.
LinuxRejectsGroupOrOtherCredentialPermissions, is a pre-existing
Linux-only lane gate that throws PlatformNotSupportedException on this
Windows host — unrelated to this change).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:03:04 +02:00
Erik
8de69b9741 fix(vt): E metaf header + STATE/NAV fold markers, real byte identity
Item E (slice-1 fix round). SaveMeta/SaveNav emitted no header at all
and no per-STATE/per-NAV editor-fold comment pair, so proof (4)
(byte-identity against metaf's own canonical emission) could only ever
pass after stripping every "~~" line — which hid that metaf's own
ExportToMetAF DOES mechanically emit both:

- OutputText.metaHeader/navHeader (metaf_monolithic.py:365-434): a fixed
  auto-completion-assistance banner, prepended to every meta/nav-only
  file respectively.
- State.ExportToMetAF / Nav.ExportToMetAF (py:12050-12054,12463-12467):
  every STATE:/NAV: block wrapped in "~~ {" ... "~~ }", unconditionally
  (including the single-node Target/follow NAV case).
- Meta.ExportToMetAF (py:12775-12794): when any NAV exists, a blank
  line, the exact separator
  "~~========================= ONLY NAVS APPEAR BELOW THIS LINE =========================~~"
  (no space beside either "~~"), and another blank line, before the
  first NAV: block.

Changes:
- MetafSerializer: MetaHeaderLines/NavOutputHeaderLines — the header
  text copied byte-for-byte from Fixtures/vtank/af/bella.af (meta) and
  nav_ab.af (nav-only) rather than retyped from the Python source, per
  the slice-1 contract. Joined with bare "\n" (matching metaf's own
  multi-line string constant, written as ONE f.line entry) plus a
  trailing "\n" — combined with this writer's own "\r\n" join separator,
  reproduces the single blank line real output has between the header
  and the first STATE:/NAV: line exactly.
- SaveMeta: prepends MetaHeader; wraps every STATE: block in "~~ {"/
  "~~ }"; emits the "ONLY NAVS APPEAR BELOW..." separator only when at
  least one embedded Nav tag exists.
- WriteNavBlock: wraps every NAV: block (both waypoint-list and
  Target/follow modes) in "~~ {"/"~~ }".
- SaveNav: prepends NavOutputHeader; writes the sole nav under tag
  "nav0" (matching metaf's Meta.GenerateUniqueNavTag counter starting at
  0 — every committed nav_*.af fixture's tag is literally "nav0";
  the prior tag "route" was a MossTank invention).

Proof (4) is now REAL byte identity (Assert.Equal(original, rewritten),
no comment stripping, no blank-line normalization) plus a new nav-only
case (WriterOutputMatchesMetafCanonicalEmissionNavOnly, against
nav_ab.af). Investigating the raw bytes of every candidate fixture
found real, PRE-EXISTING header divergence unrelated to this writer:
aphus.af/neftet.af/follower.af open "~~ {\r\n~~ " (CRLF) where a fresh
metaf conversion's header is bare-LF internally ("~~ {\n~~ ",
confirmed against augments.af/bella.af/gauntlet_leader.af/
empyrean_facility.af/example_sort_meta.af) — evidence of a re-save by
something other than metaf itself (e.g. a text editor normalizing every
line ending). hunting.af and lockandkey.af carry a wholly custom
hand-written banner instead of metaf's own. ByteIdenticalFixtureData is
narrowed to the five fixtures whose header IS metaf's fresh canonical
form (bella, gauntlet_leader, empyrean_facility, augments,
example_sort_meta — still five, per the slice-1 contract's floor);
aphus/neftet/hunting/follower/lockandkey keep exercising every other
proof (parse, parse-write-parse, and the ptl/tlk direct assertions from
the item-A fix) normally, with the header divergence documented at the
exclusion site rather than asserted away.

Three MossTankPanelTests assertions changed from StartsWith to Contains
("STATE: "/"NAV: ") since exported .af content is no longer the first
thing in the file.

Full MossTank suite: 570 -> 566 (5 fixtures dropped from the
byte-identity theory, replaced by 1 new nav-only fact: -4 net).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:47:08 +02:00
Erik
e1d0c703f9 fix(vt): D CreateNew seeds from VTank's real defaultsettings.usd
Item D (slice-1 fix round). CreateNew built ONLY a bespoke 4-column
Settings table (empty Description, hardcoded SettingType=1/Bool for
every row regardless of the setting's real declared type) and had no
representation of the other nine tables VTank ships at all (MyMonsters,
GemFoodItems, ExtraBuffSpells, AntiExtraBuffSpells, ItemUseSpecifiers,
SettingsCategories, SettingsEnumInfo, AssistItems, BuffedItems).
Separately, VitalSettings.RechargeHandlerRows defaulted to an empty
list, so a freshly-constructed VitalSettings (not loaded from a real
.usd) always fell back to VitalRechargePlanner's own hand-ported
handler-order switch instead of VTank's real 26-row table.

- VtankDefaultSettings.usd: the real uTank2.Resources.defaultsettings.usd
  (already committed as a test fixture) copied into the plugin project as
  an EmbeddedResource, following the existing VtankCraftRecipes.tsv/
  VtankAmmunitionOptions.tsv pattern.
- New VtankDefaultSettingsDatabase: loads the embedded text once (Lazy),
  exposes Parse() (a fresh, independently mutable VtankDatabase per call)
  and DefaultRechargeHandlerRows (the real RechargeHandlerSet table,
  parsed once via VtankSettingsProfileSerializer.ParseRechargeHandlerSet).
- VtankSettingsProfileSerializer.CreateNew now parses the embedded
  document and calls the existing Save(document, source) against it —
  every table, every row's real Description/SettingType survive
  untouched; only the Settings table's Value cells get overwritten from
  the live source, exactly as re-saving an existing profile would.
  DefaultCell (the hand-typed per-catalog-kind cell builder CreateNew
  used before) is deleted as dead code.
- VitalSettings.RechargeHandlerRows now defaults to
  VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows instead of [].
  VitalRechargePlanner.Handlers' hardcoded switch-based defaults are kept
  (not deleted) as a documented, genuinely defensive fallback for the
  case where a caller explicitly clears the row list or an
  (vital, stance, percent) combination the real table happens not to
  cover — no longer the primary source of truth it used to be, and the
  doc comment says so.
- RenderRechargeHandlerSet (a write-back helper for the RechargeHandlerSet
  table, never called anywhere) is deleted rather than wired into
  Capture(): docs/research/vtank-kb/01-settings-and-profiles.md section 2
  row 137 confirms real VTank has NO save path for this table at all
  (tCustom has no case in the Advanced Options editor or "/vt opt set";
  it's owned by a dedicated cRechargeManager object). Wiring a write-back
  would be a MossTank invention, not a retail port, so Capture() keeps
  deliberately leaving this row untouched.

New CreateNewHasTheSameTableSetAsTheDefaultFixture test: asserts
CreateNew's table set matches defaultsettings.usd's own table set
exactly, and that Settings rows carry real (non-empty) Description and a
real (non-Bool-for-everything) SettingType. Verified failing against the
prior 4-column-only implementation (temporarily restored, ran, reverted):
"Expected: [AntiExtraBuffSpells, AssistItems, ...] / Actual: [Settings]".

Full MossTank suite: 570/570 passing (569 -> 570, one new test).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:33:31 +02:00
Erik
d0247dbb19 fix(vt): C separate exports/meta/ and exports/nav/ .af directories
Item C (slice-1 fix round). MossTankMetaProfileStore and
MossTankRouteProfileStore both wrote their .af exports into the same
flat "exports/" directory keyed only by profile name — a Meta profile
and a route (Navigation) profile sharing a name (e.g. both named
"Same") would silently clobber each other's .af file on save, with no
error and no warning.

- MossTankMetaProfileStore.WriteLegacyExport now writes to
  "exports/meta/{name}.af".
- MossTankRouteProfileStore.WriteLegacyExport now writes to
  "exports/nav/{name}.af".
- Both class docs updated to name the collision this avoids and point at
  the sibling store's subdirectory.
- MossTankPanelTests: existing NavCommandsImportAndExportExactVtankNavFiles
  and MetaCommandsImportAndExportExactVtankMetFiles updated to the new
  paths. New MetaAndRouteExportsWithTheSameNameDoNotCollide saves a Meta
  and a route profile both named "Same" and asserts both .af files exist
  with their own correct content — verified failing before the fix
  (asserted false on the meta file's existence once both paths were
  reverted to the flat "exports/" root, confirming the collision is real
  and this test catches it).

Full MossTank suite: 569/569 passing (568 -> 569, one new test).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:26:17 +02:00
Erik
e5bc5c6a3f fix(vt): B typed MetaAction.EmbeddedRoute replaces the binary nav blob
Item B (slice-1 fix round). A LoadEmbeddedNavigationRoute action carried
a synthesized "uTank2 NAV 1.2" TEXT blob in MetaAction.Text that both
MetaEngine and the runtime consumer (MossTankPanel) had to re-parse on
every load — an unnecessary re-parse of already-typed data, and the
reason proof (3)'s AssertActionsEqual skipped comparing embedded routes
entirely (the blob's exact byte shape wasn't a meaningful comparison
target).

- MetaAction: new EmbeddedRoute (NavigationSettings?) property replaces
  the blob. Null only for a genuinely unresolved/never-defined Nav tag.
- MetaServices.LoadEmbeddedNavigationRoute: Action<string> ->
  Action<NavigationSettings?>; MetaEngine's dispatch passes
  action.EmbeddedRoute directly.
- MetafSerializer.ResolveEmbeddedNavs (.af importer) builds the
  NavigationSettings directly via the existing ApplyNavBody helper
  (renamed SynthesizeNavBlob -> BuildNavSettings) instead of serializing
  it back into text. WriteBinaryNavBlob/WriteBinaryWaypoint/
  FormatBinaryDouble are deleted outright (the only caller was the
  now-removed blob synthesis).
- SaveMeta had its own now-dead re-parse of action.Text via
  VtankNavRouteSerializer.TryLoad to rebuild each embedded NAV: block on
  save; this silently started producing EMPTY NAV: blocks once Text
  stopped carrying the blob (Text is now always cleared for this action
  kind), caught immediately by the full suite: MetaParseWriteParseIsIdentical
  started failing "Expected: Once / Actual: Circular" (a re-parsed route
  falling back to NavigationSettings' default Mode because its NAV: block
  vanished). Fixed by writing straight from action.EmbeddedRoute.
- VtankMetaProfileSerializer (.met importer): TryLoad gained an
  ISpellCatalog overload (threaded through ReadAction/ReadEmbeddedNavigation);
  ReadEmbeddedNavigation now parses its reassembled blob text through
  VtankNavRouteSerializer.TryLoad into a real NavigationSettings instead
  of handing the raw text to the caller. The existing 2-arg TryLoad
  overload defers to MetafSerializer.NoOpSpells.Instance (promoted from
  private to internal) so both test call sites and MossTankMetaProfileStore
  (which now passes _host.Automation.Spells) keep working.
- VtankNavRouteSerializer.Apply promoted from private to internal so
  MossTankPanel.LoadEmbeddedNavigationRoute can copy an already-typed
  NavigationSettings into the live _navigationSettings instance directly,
  replacing its own VtankNavRouteSerializer.TryLoad(string, ...) re-parse.
- MetafSerializerTests: AssertActionsEqual now asserts
  EmbeddedRoute is non-null on both sides and calls AssertNavigationEqual
  on them (waypoint-by-waypoint) for LoadEmbeddedNavigationRoute actions,
  instead of skipping the comparison. PtlNodeKeepsBothCoordinateTriplesDistinct's
  FindNavWaypoint helper reads action.EmbeddedRoute directly instead of
  re-parsing action.Text (which is now empty).

Full MossTank suite: 568/568 passing (net zero change in count — this
is a representation change, not new coverage, though the AssertActionsEqual
tightening now exercises real waypoint comparisons on every fixture with
an EmbedNav action that it previously skipped).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:22:39 +02:00
Erik
0219c6e03d fix(vt): A ptl/tlk carry two coordinate triples, jmp direction loss recorded
Item A (slice-1 fix round). VTank/metaf's Portal2/UseNPC nav nodes carry
TWO coordinate triples (metaf_monolithic.py:356-357,11482,11618 —
"FORMAT: ptl/tlk myx myy myz tgtx tgty tgtz tgtObjectClass tgtName"): the
outer header ("myxyz", retail's own dead-weight last-save player position
per docs/research/vtank-kb/06-navigation-and-nav.md section 1.2) and the
embedded d-record ("tgtxyz", the real target coordinate used to match a
live world object by name+class+proximity). The prior port's
RouteWaypoint had a single Position field, so both the .af reader
(MetafSerializer.ReadNavNode) and the binary .nav reader
(VtankNavRouteSerializer.ReadWaypoint, case 6/7) overwrote "myxyz" with
"tgtxyz" on load, and the .af writer echoed the same Position value for
BOTH triples on save — a real .af round trip of the same waypoint was
lossy, which is why aphus/augments/lockandkey/neftet were excluded from
the byte-identity proof.

- RouteWaypoint: new ReferencePosition field (Position stays "myxyz",
  ReferencePosition is "tgtxyz"); included in Clone().
- MetafSerializer.ReadNavNode/RenderNavNode: ptl/tlk read/write both
  triples distinctly. WriteBinaryNavBlob's embedded-route writer (the
  MossTank runtime blob EmbedNav actions carry) fixed the same way — it
  was echoing Position for the reference triple too.
- VtankNavRouteSerializer.ReadWaypoint case 6/7: keep the header triple in
  Position, read the trailing triple into ReferencePosition instead of
  overwriting Position.
- Navigation.TickUse: TryFindObject now searches near ReferencePosition
  (the real target coordinate) instead of Position, preserving the
  correct runtime search behavior now that Position no longer aliases it.
- MossTankPanel.AddSelectedObjectWaypoint: new Portal2/UseNPC waypoints
  now set Position from the live snapshot (matching retail's own
  "wherever the character stood") and ReferencePosition from the selected
  object's live position (the real search anchor) — previously both were
  set from the object's position.
- MossTankRouteProfileStore's WaypointDocument DTO carries the reference
  triple too, so MossTank's own JSON-persisted routes round-trip it.
- MetafSerializerTests: un-excluded aphus/augments/lockandkey/neftet.af
  from the byte-identity proof (they all embed a ptl/tlk node and now
  round-trip correctly) and added example_sort_meta.af, which also
  passes. bore_quest.af was NOT added despite the slice-1 contract's
  ask: it is hand-edited the same way as the already-excluded
  bore_enhanced.af (space instead of tab between "IF:"/"DO:" and the
  following keyword, confirmed at bore_quest.af line 9 — metaf's own
  Rule.ExportToMetAF always joins with a tab, metaf_monolithic.py:12371),
  so it can never byte-match; documented alongside bore_enhanced's
  existing exclusion note instead. New PtlNodeKeepsBothCoordinateTriplesDistinct
  test pins the two-triple split directly (failed before this change:
  Position held the second triple with nowhere to read the first triple
  back from). VtankNavRouteSerializerTests updated to assert the split
  instead of the old collapsed value.
- jmp direction: metaf's NJump class has no strafe-direction field at all
  (metaf_monolithic.py:11708-11821, confirmed reading ImportFromMetAF/
  ExportToMetAF end to end) — the .af format cannot represent
  RouteWaypoint.JumpDirection, full stop. ReadNavNode no longer assigns
  JumpDirection = Forward explicitly (the model's own default), and the
  loss is now recorded as gap 9 in docs/research/vtank-kb/
  06-navigation-and-nav.md section 6.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:13:48 +02:00
Erik
68627a873b docs(vt): slice-1 ledger — Part A fix round 1 (3/12) and round 1b dispatched
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:55:10 +02:00
Erik
07664b199b fix(vt): make the .usd cursor character-oriented for ba blobs and unknown tags
Fidelity blocker 6: VtankLineCursor pre-normalized every "\r\n" to "\n"
across the WHOLE document before splitting into lines, then ReadBlob
re-joined consumed lines with a single '\n'. VTank's real reader
(f6.cs:10-17) is `TextReader.Read(array, 0, num)` — it reads exactly N
raw characters straight off the stream, so an embedded CRLF inside a
"ba" blob costs 2 characters toward that length, not 1. The prior
cursor silently dropped that extra character, corrupting any blob
whose content used CRLF line breaks and desyncing the parse position
for everything that follows it in the same row/table.

Separately, VtankDatabaseReader.ReadCell's default case unconditionally
consumed a "value" line for ANY unrecognized tag. VTank's own reader
(gy.cs:50-55) and writer (gy.cs:98-101) treat every tag outside
{d,i,u,f,s,b,TABLE,ba} as void/unrecognized (y.cs:28-41 registers only
TABLE and ba as named custom types) and consume/emit ONLY the tag line
— no value line either way. The prior default case would misread the
next cell's own tag as this cell's bogus value, corrupting the rest of
the row exactly like the blob bug above.

- VtankLineCursor now indexes directly into the original (unnormalized)
  text: ReadLine() scans for '\n' and strips one trailing '\r' per line
  (matching StreamReader.ReadLine() semantics); ReadBlob(length) takes
  exactly `length` raw characters from the current position with zero
  reinterpretation.
- VtankCell.WriteTo (and Row/Table/Database) now build a single
  StringBuilder instead of a `List<string> lines` that assumed one
  entry == one line: a "ba" blob writes its length line then the RAW
  blob content with NO added line terminator (matching f6.cs's
  WriteLine(length) + Write(content), not WriteLine(content)) — the
  next structural token continues immediately after the blob's last
  character, exactly like real VTank output.
- ReadCell's default case now builds a bare unknown-tag cell (no
  ScalarText/BlobText, consuming nothing further); WriteTo mirrors this
  by emitting nothing after such a tag's own line.
- Bonus (adjacent, from the same gy.cs read): VtankCell.String now
  strips embedded '\n' on write (gy.cs:84's `text.Replace("\n", "")`),
  matching VTank's own string-cell writer exactly — a partial down
  payment on item 12's "strip \n from string cells on write" nit.

New tests (tests/AcDream.Plugins.MossTank.Tests/VtankUsdDocumentTests.cs):
BaBlobWithEmbeddedCrlfRoundTripsExactCharacterCount,
BaBlobLengthCountsEmbeddedCrAndLfAsSeparateCharacters, and
UnrecognizedTagConsumesOnlyItsOwnLineNotTheNextCellsValue. All three
were verified failing against the pre-fix VtankUsdDocument.cs (restored
from HEAD, tests re-run, then reverted back) before this change:
the first two threw FormatException "unexpected end of file", the
third read "s" (the next cell's own tag) as ScalarText instead of null.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:52:39 +02:00
Erik
74283e1116 fix(vt): model UseArcs as VTank's real 3-way enum, not a collapsed bool
Fidelity blocker 2: VTank's UseArcs is 1=No / 2=AtRange / 3=Yes
(refs/vtank/decompiled/hi.cs:515-538, switch on f3.f("UseArcs")): No
always picks the direct-shape spell, AtRange picks arc only once the
target reaches ArcRange, and Yes always picks arc regardless of
distance. The prior port collapsed this onto CombatSettings.UseArcs
(bool), which can represent No and (an approximation of) AtRange but
has no way to express Yes — a profile with UseArcs=3 could never
actually always-arc, and Capture() had to fudge 1<->3 on save,
which is why UntouchedRoundTripIsByteIdentical needed a NormalizeUseArcs
special case.

- CombatSettings: new UseArcsMode enum (No=1, AtRange=2, Yes=3);
  UseArcs is now UseArcsMode (default AtRange, matching the previous
  bool default's runtime behavior).
- AttackSpellCatalog.ShouldUseArc implements the real 3-way switch,
  replacing the `settings.UseArcs && target.Distance >= settings.ArcRange`
  expression at both call sites (Yes now genuinely always arcs).
- VtankSettingsProfileSerializer Apply/Capture "usearcs" cases now
  cast directly to/from UseArcsMode instead of the `!= 0` / `? 3 : 1`
  bool collapse.
- MossTankPanel GetMetaOption/SetMetaOption "usearcs" now exposes the
  raw 1-3 value (ExpressionValue.Number / AsInt32 clamped 1-3),
  matching the existing pattern for DebuffEachFirst/DebuffSelectionMethod,
  instead of ExpressionValue.Boolean/IsTruthy.
- MossTankProfileStore's JSON DTO field type follows suit (System.Text.Json
  already serializes CombatSettings' other enum settings the same way).
- Deleted NormalizeUseArcs from UntouchedRoundTripIsByteIdentical —
  the test now asserts full byte-identity with no special case, and
  CaptureMatchesDeclaredSettingTypeAndValue (added in the previous
  commit) no longer needs to skip "UseArcs".

Verification: reverting to the bool model reproduces exactly one
theory failure (CaptureMatchesDeclaredSettingTypeAndValue("UseArcs"):
expected 1, actual 3) confirming this is the only affected setting;
after this change the full 559-test suite passes with zero special
cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:47:03 +02:00
Erik
9c34b6074d fix(vt): Capture() emits the declared eSettingValueType tag, not the CLR field type
Fidelity blocker 1: 17 tInt Settings rows (Recharge-*,
IdleBuffTopoffTimeSeconds, RebuffTimeRemainingSeconds,
BlacklistMonsterTimeoutSeconds, GhostDeleteHPTrackerSeconds,
DebuffPrecastSeconds, BuffCastRecast(Reset)_Seconds,
BlacklistCorpseOpenTimeoutSeconds) were written as VtankCell "d" and 3
tSingle rows (CollisionProjectileRadius, CollisionStepDistance,
SpellRangeFudge) were written as "f", while VTank's shipped
defaultsettings.usd Settings.SettingType column declares them tInt(3)
and tDouble(2) respectively (refs/vtank/decompiled/uTank2/eSettingValueType.cs).
VTank's gy.e/gy.f unbox helpers (refs/vtank/decompiled/gy.cs) throw
InvalidCastException reading a mistagged cell, so a profile MossTank
wrote back would corrupt the next VTank load.

Verified against the real fixture with a small parser
(defaultsettings.usd's own SettingType column) rather than trusting a
second hand-written table: exactly 20 mismatches, matching the review's
count precisely.

- VtankOptionCatalog: added VtankSettingValueType (VTank's
  eSettingValueType) and a 137-row DeclaredType lookup transcribed
  verbatim from the fixture's SettingType column.
- VtankSettingsProfileSerializer.Capture: every numeric arm now routes
  through Num(name, value), which wraps the value using
  VtankOptionCatalog.DeclaredType(name) instead of a hardcoded
  VtankCell.Int/Double/Float call.
- VtankCell.FormatDouble now formats "G15" (VTank's own
  Convert.ToString(double) under classic .NET Framework), not .NET's
  shortest-round-trippable default — verified against every "d" value
  in the real fixture.
- ValuesEqual is now an exact per-tag compare (bool/int/uint/string
  exact; double/single bounded by a named float-round-trip epsilon
  documented as such, not an arbitrary tolerance) instead of lumping
  every numeric tag into one loose comparison.

New test CaptureMatchesDeclaredSettingTypeAndValue is a theory over all
135 catalog names with a live write path (skipping EnableMeta,
RechargeHandlerSet, and UseArcs — pre-existing exclusions), asserting
Capture()'s tag and value against defaultsettings.usd's own Settings
row rather than a second hand-authored expectation table. It failed on
the 20 known-bad names before this fix (see conversation record) and
passes now.

Deliberate, documented deviation from the review's literal "delete the
1e-6 tolerance" instruction: several distance settings (AttackDistance,
ArcRange, …) are declared tDouble but still round-trip their live value
through a CombatSettings `float` field for the physics/combat math that
consumes it; a fully exact compare would make Save() rewrite those rows
on every untouched load due to sub-15-significant-digit float noise,
regressing the class's own byte-identity goal. The retained tolerance
is now named (FloatRoundTripTolerance, float's ~7-digit relative
epsilon) and scoped to only the "d"/"f" tags, not blanket over
every numeric tag as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:45:53 +02:00
Erik
208599fae7 docs(vt): slice-1 ledger — full App suite on the merged branch (36 pre-existing env-gated failures)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:43:47 +02:00
Erik
467cb1c381 docs(vt): slice-1 ledger — Part B merged at da0fcb374
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:42:20 +02:00
Erik
da0fcb3741 merge(vt): slice 1 Part B — multi-column <list> markup (review-closed)
Campaign VT slice 1 Part B: <list><column type=text|check|icon> with
per-column bindings, text onclick, width="*" auto share, row-bound
callback guards, shared UiCheckLamp, Meta/Monsters-shaped end-to-end
tests. Two Opus lenses + fix round + narrow re-review: MERGE-READY.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:40:54 +02:00
Erik
b44282071c docs(vt): owner clarification — VTank has no real API; acdream may create its own plugin API later
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:35:18 +02:00
Erik
4ce6ffe6ff docs(vt): KB index counts match the verified docs (49 instantiations / 66 entries; 11 waypoint types)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:34:41 +02:00
Erik
bda8745448 docs(vt): owner decision — no interop; slice 9 dropped, all plugins rewritten against acdream's own API
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:34:30 +02:00
Erik
d078863306 docs(vt): owner sharpened the UI decision — resemblance + same interactions, not VVS internals
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:32:02 +02:00
Erik
026b512074 fix(vt): list column fix round 11/11 — docs for the whole fix round
docs/plugin-ui-markup.md's Columns section is rewritten to match every
production change from items 1-9:

- New "Width semantics" section: width="*", the last-column-always-auto
  rule (and how it combines with an earlier explicit "*"), the
  non-last-column Build-time throw, and the runtime overflow clamp.
- The <column> attribute grammar table gains column onclick
  (type="text", optional, Action<int>) and points width at the new
  section instead of the retired "last column ignores its own width"
  one-liner.
- New "Short columns past their own row count" section: text/icon draw
  nothing, check draws unchecked.
- Check-column glyph section updated for the shared UiCheckLamp
  primitive and its horizontal centering.
- New "The PITCH convention for transcribing a VTank column table"
  section: VVS's WPaddingOuter=3px/WPadding=7px/16px scrollbar reserve/
  forced-13px-check-columns have no direct acdream equivalent, so
  transcribing a real mainView.xml column table means declaring each
  column's PITCH (fixedwidth+7, or 13+7 for a check column regardless
  of its real fixedwidth) and reserving 16px on the last column.
- The worked example now uses rowheight="18" (VVS's own Padding*2+
  ControlHeight pitch, already the widget's default) and PITCH-computed
  widths (20 for a check column, 127 for a 120px text column) instead
  of arbitrary numbers, with a text onclick and a width="*" icon
  column added to demonstrate both new attributes in place.
- The bindings truth table gains column onclick (type="text") and both
  column width throw/silent rows (non-last vs. the list's last column).
- Testing conventions paragraph updated to mention width semantics, the
  row-bound click guard, and the two end-to-end tests.

Verification: dotnet build AcDream.slnx -c Release (green), dotnet test
tests/AcDream.App.Tests -c Release --filter
"FullyQualifiedName~Markup|FullyQualifiedName~UiMarkupList|FullyQualifiedName~PluginSidePanel"
(130/130), dotnet test tests/AcDream.Plugins.MossTank.Tests -c Release
--filter "FullyQualifiedName~Markup" (9/9) — closes the fix round.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:31:58 +02:00
Erik
e178c6abac fix(vt): list column fix round 10/11 — Meta/Monsters-shaped end-to-end tests
Two tests built through the full MarkupDocument.Build parse -> bind ->
draw (recording renderer) -> click pipeline, transcribing the two real
VTank list shapes named in the fix round brief
(refs/vtank/uTank2.ViewXML.mainView.xml):

- EndToEnd_MetaShapedSixColumnList: lstMetaRules's shape (3 icon
  columns, a 150px text column, then two width="*" text columns, the
  second with an onclick) at a list width (703) chosen so the two auto
  columns' 505px leftover does NOT divide evenly (252/253) — proving
  the last one absorbs the rounding slack. Asserts each column kind
  draws inside its own x-range and a click in each of the six columns
  reaches its own bound callback with the right row (icon columns
  fire their own onclick; the plain text column still selects; the
  onclick-bearing trailing text column fires that instead).

- EndToEnd_MonstersShapedTwentyThreeColumnList: lstMonsters's shape
  (14 check columns, 7 text columns each given an onclick per fix item
  1, 2 icon columns) built from real fixedwidth values with NO "*"
  anywhere, at a list width (724) equal to the exact sum of every
  column's declared width — the "no auto column, last absorbs the
  remainder as today" case. Asserts the column-kind counts, each drawn
  kind's x-range, and one representative click per kind reaches its own
  callback (other same-kind columns share a dummy callback, proving the
  bound-per-column dispatch, not just "some column of that kind fired").

Both use iconkind="item" for their icon columns (not the default
"did") so the fake resolver's echoed id is the literal texture drawn,
sidestepping PluginIcons.Normalize's did-namespace OR — an incidental
discovery while writing these (the first draft asserted on raw ids and
failed against the normalized 0x06000000-tagged textures).

Verified meaningful two ways: both failed first against the pre-fix
raw-id assumption (texture ids came back normalized), and — a stronger
check specific to these integration tests, since the underlying
mechanics were already covered by earlier fix-round commits — a
temporary mutation of ColumnLayout's auto-column share computation
(forcing share=0) made EndToEnd_MetaShapedSixColumnList fail exactly as
expected before being reverted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:28:10 +02:00
Erik
92f9b36b8f docs(vt): slice-1 ledger — Part A landed, both review verdicts, two-round fix plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:26:21 +02:00
Erik
0f79ee03ee fix(vt): list column fix round 9/11 — MossTankMarkupContractTests learns <column>
The contract test suite's per-element binding-shape check treated
EVERY element's onclick as a plain Action, and its interactive-element
set (used both for "declares a real handler" and "has an accessible
tooltip") had no entry for <column> at all — both would misvalidate a
future mosstank.xml <column> the way Campaign VT slice 1 Part B and
this fix round's item 1 actually shipped it (onchange/onclick as
Action<int>, the row index).

Extracted the per-element dispatch out of
EveryInteractiveBindingMatchesTheRetainedUiDelegateShape into
AssertElementBindingsMatchRetainedUiDelegateShape, which now
special-cases "column": both onchange and onclick must resolve to
Action<int>, never plain Action. The interactive-element name list is
now the single shared InteractiveElementNames array (was duplicated
inline in EveryInteractiveControlDeclaresARealHandlerBinding and
TextlessAndAbbreviatedControlsHaveAccessibleRetailTooltips), with
"column" added — zero behavior change against mosstank.xml today (it
has no <column> elements yet), confirmed by the existing 190-control
count assertion staying green.

New tests: InteractiveElementNames_IncludesColumn pins the addition
directly; Column_OnchangeAndOnclick_MustBeActionOfInt builds a
synthetic <column> (not loaded from mosstank.xml, which has none) and
proves both a correctly-typed Action<int> onchange passes and a
column onclick bound to a plain Action (the shape every OTHER
element's onclick uses) is rejected — confirmed by temporarily
removing the "column" special case and rerunning: the plain-Action
onclick then passed silently ("No exception was thrown", the
generic Action check being satisfied by a real Action property),
proving the test only passes because the fix's column-specific
Action<int> enforcement is present.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:20:19 +02:00
Erik
8fb4909cc8 fix(vt): list column fix round 8/11 — shared UiCheckLamp + factory-only column
New internal static UiCheckLamp (LampSize=11f, the four checked/
unchecked colors, Draw) is the ONE definition of the five-band lamp
glyph, promoted out of UiMarkupToggle's private/internal fields —
UiMarkupToggle.OnDraw and UiMarkupList.DrawCheckCell both call
UiCheckLamp.Draw now instead of each carrying (or one exposing to the
other) its own copy of the same five DrawFill calls and four colors.

DrawCheckCell also centers the lamp horizontally in its cell instead of
drawing it flush left at cellX+1 — matching DrawIconCell, which already
centers its sprite. A check column declared wider than the glyph itself
(routine under fix item 11's PITCH-based authoring convention) no longer
strands the glyph in the cell's left edge.

UiMarkupListColumn's settable members are now internal init (Kind/Width
lost their `required` modifier — C# forbids `required` pairing with a
setter less visible than the containing public type, CS9032 — every
factory already sets both unconditionally, so this is a compiler-level
demotion, not a behavior change) — the type is constructible only
through its Text/Check/Icon factories from any external assembly (a
plugin) with no InternalsVisibleTo grant, so it can never assemble an
inconsistent instance via object-initializer syntax.

Replaced the inert draw-offset assertion in
Columns_CheckThenIcon_EachCellDrawsInsideItsOwnColumnBounds (check
column FIRST, so its own-cell assertion held trivially even with
completely broken column offsets) with
Columns_IconThenCheck_EachCellDrawsInsideItsOwnColumnBounds — check
column now SECOND, so the assertion can only pass if the glyph actually
moved into its own [20,100) cell.

New test: a single 50px-wide check column's glyph lands near the cell's
midpoint (~22.5) rather than the old flush-left x=4 — shown to fail
first before the centering change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:16:00 +02:00
Erik
f8e5edb848 fix(vt): list column fix round 7/11 — short check columns draw unchecked
DrawCheckCell drew nothing for a row past its own column's bound data —
inconsistent with docs/plugin-ui-markup.md's own description of VVS's
HudList, which materializes every cell in a row regardless of which
columns actually have data for it. Text/icon cells rightly stay
no-draw past their own count (no sensible default string or icon), but
a check cell always has one: unchecked.

DrawCheckCell now computes isChecked defensively (false when the flags
array is null or too short) instead of returning early, so a short
check column draws the unchecked lamp for every row past its own data
just like every other row.

New test: a 3-row list (driven by a longer text column) with a check
column bound to only 1 row now shows the unchecked lamp for rows 1 and
2 — shown to fail first (0 unchecked lamp quads, since DrawCheckCell
returned early past index 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:09:41 +02:00
Erik
184f687691 fix(vt): list column fix round 6/11 — kill per-frame/per-event allocations
DrawColumns allocated four fresh IReadOnlyList<T>?[] sidecar arrays and
ColumnLayout allocated a fresh (float,float)[] EVERY draw call; worse,
OnEventColumns re-invoked every column's source Func a SECOND time (via
UiMarkupListColumn.RowCount()) just to recompute the same row count
DrawColumns had already materialized, and called ColumnLayout a second
time to recompute the same layout — on every single click/scroll event.

Columns is now a property with a custom setter that (re)sizes seven
instance-field caches to Columns.Count exactly once per assignment:
_cachedTextRows/_cachedColorRows/_cachedCheckRows/_cachedIconRows (the
per-column sidecar arrays), _cachedLayout (the column x/width array),
and _scratchIsAuto/_scratchFixedWidth (ComputeColumnLayout's own working
arrays, previously freshly allocated on every layout computation too).
DrawColumns writes into these caches instead of local arrays and records
_cachedRowCount; ComputeColumnLayout (the renamed, now-instance
ColumnLayout) writes into _cachedLayout in place instead of returning a
new array. OnEventColumns reads _cachedRowCount/_cachedLayout/the
per-column cached row arrays instead of re-invoking anything — this also
completes fix item 3's row-bound guard without a second RowCount() call.
UiMarkupListColumn.RowCount() stays as public API (still asserted by an
existing test) but is no longer called from UiMarkupList internally,
which is the "single row-count definition" the fix round asked for.

This does make OnEvent depend on at least one prior Draw call for
correct row/layout data (mirrors real frame order: draw every frame,
then handle input) — the four hit-test click tests that previously
fired OnEvent with no preceding Draw now draw once first, matching what
Scroll_OffsetIsRespectedBySubsequentHitTests already did.

New test: each column source Func increments its own call counter; one
Draw call invokes each exactly once, and five rounds of subsequent
click events invoke none of them again — shown to fail first (16 calls
instead of 1, from OnEventColumns's old RowCount()-per-column-per-event
re-invocation) before the caching change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:07:40 +02:00
Erik
8fa70c3e4a docs(vt): KB 01 RechargeHandlerSet seeds 26 rows, not 24 (found by the Part A implementer against the fixture)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:06:37 +02:00
Erik
17f2143a71 docs(vt): append Part A closeout ledger line to the slice-1 contract 2026-09-06 21:04:18 +02:00
Erik
5d760331f6 fix(vt): list column fix round 5/11 — every column throw names its column
BuildListColumn's per-attribute binder calls used the generic "column
items"/"column values"/"column onchange"/"column onclick" context
strings from the initial slice — indistinguishable when a list has
several columns of the same type. Route every column-attribute binder
call (text items/colors/onclick, check values/onchange, icon
values/onclick) through the ColumnContext helper item 2 introduced, so
every throw message reads column[N] type="..." attr. ValidateIconKind
gains an optional context parameter (default "iconkind" for the
existing non-column call sites — <icon>, <button icon>, <list icons>,
none of which changed message-wise beyond wording) so the column
iconkind check can identify its own column too.

Updated all twelve column-throw tests in MarkupListColumnsTests to
assert Assert.Contains on the distinguishing phrase: the ten
attribute-specific ones now check column[0] type="..." attr; the
unknown-<column type> and combined-with-legacy-attribute tests keep
(and, for the type one, add to) their existing distinguishing
assertions. Confirmed each attribute-specific assertion is meaningful
by first running the malformed-colors case against the pre-fix
"column colors must be..." message (mismatch), then landing the
production change alongside the rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:03:43 +02:00
Erik
0d10399e0e feat(vt): A2 VTank profile directory resolution + naming rules
Campaign VT slice 1 Part A, deliverable 2 (foundation only - see the
closeout note in the final report for what is not yet wired up).

VtankProfileDirectory.cs resolves the on-disk VTank profile directory
through IPluginHost.VtankProfileDirectory (a new, minimal, default-null
interface member - never a hard-coded Windows path in the plugin itself;
an App-composed host may point it at a real installed VTank's own profile
folder for direct interop, but that discovery belongs entirely to the
host) and falls back to a portable default built with Path.Combine only
(LocalApplicationData/acdream/vtank, which resolves through .NET's
XDG-aware base-directory logic on Linux). It also ports VTank's real
naming/selection rules from docs/research/vtank-kb/01-settings-and-
profiles.md section 3: the per-character auto file (--Name_Server.ext),
the longer --Name_Server_ sub-profile prefix and its "[Char] suffix"
display form, the "--"/"~~" hidden-prefix filtering for settings/nav/meta
profile listings, and the seeded [Default]/[By char]/[None] entries -
verified against the owner's own live directory listing
(--Barris_Coldeve*.usd family).

Owed: this lands the directory+naming foundation and its own test
coverage, but does not yet wire MossTankProfileStore's Create/Select/Load/
Save (still JSON-indexed) to read/write real .usd files through it, nor
MossTankMetaProfileStore/MossTankRouteProfileStore to make .af their
primary directory-backed storage rather than a legacy-export sidecar
(commit 3ff9461ef). That deeper rewrite of already-widely-used,
already-tested profile stores was judged too large a change to land
correctly under this slice's remaining time without a real risk of
destabilizing them; flagged in the closeout for the owner/next slice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:03:16 +02:00
Erik
b58668c6cd fix(vt): list column fix round 4/11 — try/finally around per-cell clip
Matches UiButton.cs:896-906 / UiElement.cs:683-720's own clip
discipline: every per-cell PushClip in DrawColumns now has its matching
PopClip in a finally, so a cell draw that throws (e.g. a plugin's icon
resolver misbehaving) doesn't leak that PushClip onto the context's
clip stack. Without this, the leaked entry combines badly with
UiElement.DrawSelfAndChildren's own outer clip pop on the way out
(it pops the wrong stack entry), permanently corrupting the shared
UiRenderContext's clip state for every draw that follows in the frame.

Added UiRenderContext.ClipStackDepth (internal, InternalsVisibleTo
AcDream.App.Tests) purely to make this provable from a test — the
number of PushClip calls not yet matched by PopClip.

New test: a column whose icon resolver throws mid-draw still leaves
the clip stack at its pre-draw depth after the exception propagates —
shown to fail first (leaked to depth 1 instead of 0, reproducing
exactly the "outer pop consumes the wrong stack entry" mechanism
described above) before the try/finally was added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:00:41 +02:00
Erik
3ff9461efe fix(vt): demote VtankMetaProfileSerializer/VtankNavRouteSerializer to import-only
Campaign VT slice 1 Part A, per the owner's 2026-09-06 "MossTank does not
implement .met and does not author .nav" direction: .af (MetafSerializer)
is now the only storage/authoring format for meta profiles and navigation
routes. Deletes both classes' Save() writers and every writer-only helper
(WriteCondition/WriteAction/WriteEmbeddedNavigation/ConditionType/ActionType/
Number/IntValue/LineWriter in VtankMetaProfileSerializer; WriteWaypoint/
WriteDouble in VtankNavRouteSerializer) - TryLoad and its read-path helpers
are untouched, so a real binary .met/.nav still imports one-shot into the
in-memory model.

MetaEngine's LoadEmbeddedNavigationRoute still needs the "uTank2 NAV 1.2"
in-memory blob shape for a resolved EmbedNav action (that's a MossTank
runtime contract, not a VTank file on disk), so MetafSerializer gained its
own small private WriteBinaryNavBlob - a deliberate, scoped duplicate of
what used to be VtankNavRouteSerializer.Save's WriteWaypoint, kept
independent of the now-import-only class.

MossTankMetaProfileStore/MossTankRouteProfileStore's WriteLegacyExport
(the "/vt meta save"/"/vt nav save" sidecar) now writes .af via
MetafSerializer.SaveMeta/SaveNav instead of the deleted binary writers.
This is a real, if partial, step toward the contract's ".af is the only
storage/authoring format" goal - full profile-directory-backed .af storage
(A2's VTank-naming-scheme directory) is separate follow-up work, noted in
the closeout.

Deletes VtankMetaProfileSerializerTests.cs entirely (it only tested the
now-deleted Save/round-trip behavior); trims the two writer-only tests out
of VtankNavRouteSerializerTests.cs, keeping every reader test intact
(LoadsEveryOfficialNav12WaypointPayload's read assertions,
LoadsEmbeddedWrapperAndDoesNotMutateOnFailure). Updates
MossTankPanelTests.cs's two "/vt meta|nav save" integration tests for the
new .af export path (the meta test's synthetic import fixture is now a
hand-authored CondAct payload instead of a call to the deleted Save).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:59:49 +02:00
Erik
ed94acb862 fix(vt): list column fix round 3/11 — per-column row-bound click guard
A short column (fewer bound rows than the list's own max-across-columns
row count) already draws nothing past its own row — DrawCheckCell/
DrawIconCell/DrawTextCell all no-op once index >= their own array's
Count. OnEventColumns's click routing didn't share that bound: a click
landing on a row the OVERALL list considers valid (row < the longest
column) but past a SHORTER column's own data would still invoke that
column's callback with a row index it never bound anything for.

Add the same per-column bound (index < columns[c].RowCount()) to the
check-column, icon-column, and text-column-with-onclick click paths —
text-without-onclick's plain SelectionChanged fallback is unaffected,
since row selection is a list-level concept already bounded by the
outer rowCount check.

New tests: a click past a short check/icon/onclick-bearing-text
column's own row count fires nothing (each shown to fail first — the
callback fired with the out-of-bound row index before this guard).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:56:44 +02:00
Erik
96ee7b3214 fix(vt): list column fix round 2/11 — width="*" auto-sizing + overflow clamp
VVS's HudList shares remaining width equally among every 0-width column
(docs/research/vtank-kb/08-ui-views.md's HudList row, Decal3XMLParser.cs
:409-418) after fixed-width columns are subtracted — the initial slice
only let the LAST column auto-size. Port the real rule:

- A non-last <column width> that is missing, unparseable, or non-positive
  throws FormatException at Build naming the column index and declared
  type (column[N] type="..." width) — UNLESS it is width="*".
- width="*" (any column, including the last) marks it auto: every auto
  column shares the list's remaining width equally, with the LAST column
  in the list (always implicitly auto, regardless of its own width or
  IsAutoWidth) absorbing the integer-division rounding slack. With no
  "*" anywhere this reduces to exactly the original "last column gets
  100% of the remainder" contract — zero behavior change for every
  existing column-less/single-"*" list.
- A declared width overflowing the list's total width is clamped to
  whatever room is left, walked left to right; every column after the
  overflow point gets 0 width (and never draws — DrawColumns already
  skips a <=0-width cell).

UiMarkupListColumn gains IsAutoWidth (internal to the width contract,
set via each factory's new optional isAutoWidth parameter); ColumnLayout
in UiMarkupList is rewritten around an explicit auto/fixed classification
pass instead of the old "only index n-1 is special" one-liner.
MarkupDocument's new ParseColumnWidth owns the Build-time validation,
shared through the same ColumnContext helper item 1 introduced.

New tests: non-last missing/unparseable/non-positive width throws
(each naming column[N] type="..." width), width="*" parses without
throwing on both a non-last and the last column, two auto columns
share the remainder with the last absorbing the odd pixel, and an
overflowing declared width clamps with later columns drawing nothing
— each shown to fail first (compile error for the missing isAutoWidth
parameter, then a wrong split before the ColumnLayout rewrite).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:54:38 +02:00
Erik
9a7e7c4ae8 feat(vt): A1 .usd engine + A3 metaf .af engine, real VTank/metas fixtures
Campaign VT slice 1 Part A. Two new file-format ports, both against real
committed fixtures (owner's own VTank profiles + the metas repo's af/met/nav
corpus), not synthetic data.

A1 — VtankUsdDocument.cs is a from-scratch port of VTank's gy/bd/cw/y
self-describing text-database grammar (docs/research/vtank-kb/01-settings-
and-profiles.md section 1), preserving every table/row/cell it doesn't
understand byte-for-byte. VtankSettingsProfileSerializer.cs maps all 137
Settings rows (VtankOptionCatalog.Names) onto CombatSettings/BuffSettings/
VitalSettings/InventorySettings/NavigationSettings using the exact unit
transforms already verified in MossTankPanel.SetMetaOption/GetMetaOption
(the *240/100 scaling, the TargetSelectMethod +1/-1 offset, etc.) — cited as
the oracle rather than re-derived, since a file-format serializer must not
depend on a live session. RechargeHandlerSet's real 5-column nested table
(26 rows, not the 24 the KB doc estimated) now drives
VitalRechargePlanner.Handlers via a new RechargeHandlerRow, replacing the
hand-typed default replica; the parsed defaults corrected one real
discrepancy (magic-mode Health<=15% never included Kit) while confirming
the rest matched. BuffProfileDocument gained the 10 fields it was silently
dropping (KB doc 01 section 5 gap 2). Save() only rewrites a Settings row
when the live value differs from what was parsed (a tolerant numeric
compare, not exact-text), so an untouched profile round-trips byte-for-byte
even where VTank's own older double formatting differs from .NET 10's.

A3 — MetafSerializer.cs ports metaf's STATE:/IF:/DO:/NAV: text grammar
(github.com/JJEII/metaf, metaf_monolithic.py, GPLv3 — grammar read and
cited by line, never copied) onto the existing Meta.cs/Navigation.cs
models. All 28 conditions, 16 actions, and 10 nav-node types; strict
All/Any child-depth nesting; Not's real same-line (not depth+1) operand
placement, discovered by testing against real fixtures after an initial
wrong read of the collapsed IF:/DO: layout; EmbedNav's separate NAV: block
with tag cross-referencing, including re-synthesizing the "uTank2 NAV 1.2"
blob MetaEngine already expects. The jump-charge 2000ms clamp (KB doc 06
row 5) is applied at .af load. All four contract proofs pass: every real
.af parses; parse-write-parse is model-identical; our binary-.met and
.nav import matches the same content loaded from metaf's own .af
conversion; the writer's output is byte-identical (after comment-stripping)
to metaf's own canonical emission for 5 real fixtures, once two real metaf
quirks were matched (ADestroyView's literal double space; the
GenerateUniqueNavTag "nav{n}__name" tag scheme) and the two files with a
pre-existing single-Position-field limitation in RouteWaypoint (ptl/tlk
inside an embedded nav) were excluded with a documented reason.

Also: the .utl BuffedInt/BuffedDouble base-key-exists gate (KB doc 05
section 2.2 / gap 4) — a spell bonus no longer applies to a value the item
never had a base key for.

Fixtures: the owner's own defaultsettings.usd/owner-{a,b,c}.usd+.ast,
4 .utl loot profiles, and a hand-picked set of real metas-repo .af/.met/.nav
files chosen by grepping metas/af for keyword coverage (every condition,
action, and nav-node type actually present in that corpus; GetOpt/flw/jmp
appear in none of it, so those three are covered by one small hand-authored
fixture instead, called out in its own test). Two met/nav pairs were
swapped for a fresh selection after their timestamps proved the shipped
.af had drifted from a since-re-recorded .nav (a real data-consistency
issue in the source repo, not a port bug).

Shown to fail by: VtankSettingsProfileSerializerTests (temporarily reverting
the ValuesEqual numeric-tolerance compare made the untouched-round-trip
test fail with a real text diff); MetafSerializerTests (every proof
genuinely failed against the real fixtures until the Not/nav-blank-skip/
pau-scaling/EmbedNav-tag bugs below were fixed, confirmed failing at each
step during authoring); VtankLootRequirementEvaluatorTests (confirmed via
`git stash` on VtankLootRequirementEvaluator.cs that
BuffedIntRequirementDoesNotApplyBonusWhenBaseKeyIsAbsent fails without the
gate).

Deviation: added -text entries to the root .gitattributes, scoped to only
the new tests/AcDream.Plugins.MossTank.Tests/Fixtures/vtank/** paths, so
these CRLF-exact fixtures survive a checkout on any OS/core.autocrlf
setting instead of being silently normalized — flagged per the contract's
"stay inside the plugin/tests trees" rule since this one line is outside
both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:52:51 +02:00
Erik
fd521f7816 fix(vt): list column fix round 1/11 — text column optional onclick
None of VTank's eight HudList instances relies on row selection
(PluginCore.cs Monsters/Items/Meta/Route/Buffs/Consumables cell
handlers) — every real text cell is an action target. Add an optional
onclick="{Action<int>}" to <column type="text">: when present, a click
fires it with the row index INSTEAD of selecting; when absent, the
original select-the-row behavior is unchanged. BuildListColumn now
takes the column's own index (used for its onclick throw message and
threaded through for the rest of the fix round's column-context
messages); the unknown-<column type> throw also picked up the index
as a side effect of that signature change.

New tests (MarkupListColumnsTests): the onclick attribute binds and
falls back correctly when absent, a malformed onclick throws at Build,
and a click actually reaches the bound callback instead of firing
SelectionChanged — each shown to fail first (missing TextClicked
member / no 4-arg Text() overload before the production change).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:49:43 +02:00
Erik
054bf32605 docs(vt): slice-1 contract amended from the Part B reviews (text onclick, auto width, no header); review ledger
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:34:18 +02:00
Erik
dc91245ba8 docs(vt): KB 05 second citation pass — 31 requirement types, f0.c categories and dz.o.h readers resolved
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:25:19 +02:00
Erik
959a694823 feat(vt): Campaign VT slice 1 Part B — multi-column <list> markup
VVS HudList parity (docs/research/vtank-kb/08-ui-views.md 2-3): <list>
accepts <column type="text|check|icon" width=... [iconkind] [colors]/>
children, each binding its own per-row source parallel to the row count.
Row count is the longest bound column; a click in a text column selects
(the list's own selected/onchange, unchanged), a click in a check/icon
column fires that column's own required onchange/onclick with the row
index and does NOT change selection. Last column always absorbs the
remaining width, recomputed live off the list's own Width rather than
baked in at Build. Per-cell horizontal clipping via UiRenderContext's
existing PushClip/PopClip. The check-column glyph reuses <toggle>'s own
five-band lamp primitive (UiMarkupToggle.DrawLamp/colors promoted from
private to internal) so it looks like every other checkbox. Unknown
column type, a missing required column binding, <column> combined with
the legacy items/icons/colors list attributes, or any non-<column> child
of <list> all throw FormatException at Build. A column-less <list> is
byte-for-byte the original single-text-column widget (new if/Columns
branch in OnDraw/OnEvent; the legacy branch's code is untouched).

New UiMarkupListColumn model (src/AcDream.App/UI/UiMarkupListColumn.cs)
carries each column's kind/width/bindings; MarkupDocument's `list` case
now builds either the legacy single-column fields or a Columns list,
never both. MarkupListColumnsTests (27 new tests) cover parse/binding
validation, draw-level column-offset/clipping/check-glyph/icon pins
against the recording renderer, hit-test routing, and a backward-
compatibility proof (a column-less list built through MarkupDocument
produces a byte-identical draw record to a hand-built UiMarkupList with
equivalent fields). Every new assertion was verified to fail first via
targeted temporary mutations (hit-test isolation, clip removal, last-
column-absorbs-remainder, required onchange/onclick, row-count = max),
each reverted after confirming failure.

docs/plugin-ui-markup.md gets a full Columns section (attribute grammar,
a Monsters-tab-style example, the no-header-row idiom, backward
compatibility) and the old single-text-column LIMITATION note is
retired; the bindings truth table gains the six new column-attribute
rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:21:16 +02:00
Erik
4549eb02af docs(vt): VT2 ledger records the folded citation pass and the re-judged rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:19:33 +02:00
Erik
caf7307522 docs(vt): fold the citation-pass corrections into KB 06 (0.01° turn threshold, OpenVendor busy paths, cm sites vs instantiations, resolved f9.j and item.c meanings, path/count nits)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:19:19 +02:00
Erik
d8f85848b6 docs(vt): KB 08 gap table gains the menu-overflow row the citation pass asked for
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:18:18 +02:00
Erik
69bb164845 docs(vt): fold the citation-pass corrections into KB 08 (icon-id decode, menu wraps not scrolls, slider fixed 0-1, HudList press-point click, dc.xml, resolved clRings and loot-editor call site); repair KB 09 path
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:18:07 +02:00
Erik
19a32cde2e docs(vt): KB 09 header points at the real UtilityBelt tree; slice-1 A3 carries the Change-vs-Bonus fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:17:11 +02:00
Erik
ee901b7f26 docs(vt): fold the citation-pass corrections into KB 05 and 09
05: recognized-vs-unrecognized rule payloads, 25 materials, only 3-arg
SpellInfo entries are live, Read/User1..5 unreachable from any loot plugin,
hv.a.e is a dead field, id.a.j = shareLoot, the Change-vs-Bonus branch
selector mis-port in the evaluator.
09: obtest location, the broken params dedup overload, the real ah call
sites, internal relay ctor, the next-waypoint handler at 3621-3639,
TryHandle is host-only, Classic Looter registry narrowed, UtilityBelt's
Harmony bypass route resolved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:16:56 +02:00
Erik
642eacc13c docs(vt): fold the citation-pass corrections into KB 07 (hn.cs line map, dh/q hierarchy, 74-function count, ReturnFromCall guard, resolved fm/a shapes)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:15:22 +02:00
Erik
b7ed65bc64 docs(vt): VT2 slice 4 re-judged after the KB 04 citation pass (G4 is ordering only)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:04:24 +02:00
Erik
2e36ea8268 docs(vt): fold the citation-pass corrections into KB 04 (Armor Self row, stance legend swap, cr registered twice, G4 global pool, resolved handler-table and HP-yield bullets, MossTankPanel line sweep)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:04:10 +02:00
Erik
887242cd15 docs(vt): fold the citation-pass corrections into KB 02 (ga.a line map, gj/gs identity, fd.cs IdlePeace gate scope, VitalPlan.Threshold branch, entry counts)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:02:39 +02:00
Erik
1b81836747 docs(vt): fold the citation-pass corrections into KB 03 (DebuffScheduler.cs path, DebuffScope, six-member l enum, offhand enum, pet comparison owed)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:59:25 +02:00