From d2a2a602f940385853548aadd0774b059a292d40 Mon Sep 17 00:00:00 2001 From: Daniel Sperber Date: Tue, 4 Aug 2026 20:02:10 +0000 Subject: [PATCH 1/9] add CLAUDE.md --- CLAUDE.md | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..27e97bc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,244 @@ +# DScript — Squirrel scripting framework for Thief 1/2 & System Shock 2 + +Author: Daraan. Runs inside **NewDark ≥ 1.25** (`squirrel.osm`) and the **DromEd** editor. +Branch `DScript-2` is a **pre-alpha V2 rewrite** (`DScriptVersion = 0.81`) that split the old +monolith into layers. Header of `DScript Core.nut` says it plainly: *"This is not a stable +release … only minimally tested. The DHub script should not work in this version."* + +There is no build system, no package manager, no tests. The `.nut` files are dropped into +`/sq_scripts/` and compiled by the engine at load. + +--- + +## Where to find what + +### Active V2 code — edit these + +| File | Contents | +|---|---| +| `DScript Core.nut` | **The framework.** `DScript` library table, `DBasics`, `DBaseTrap`, `DRelayTrap`, `DTrigger`, `DScriptHandler`, `DHub`, QVar system (`DTrapSetQVar`, `DTrigQVar`, `DTrapDeleteQVar`) | +| `DScript General.nut` | Gameplay traps: `DStdButton`, `DHitScanTrap`, `DWatchMe`, `DCopyPropertyTrap`, `DAddScript`, `DCompileTrap`, `DStackToQVar`, undercover scripts (`DImUndercover`, `DNotSuspAI*`, `DGoMissing`) | +| `DScript SFX.nut` | Visual/inventory/camera: `DRay`, `DArmAttachment`, `DFocusObject`, `DFocusOverTime`, `DDirector`, `DHudObject`, `DHudCompass`, `DInventoryMaster`/`DSubInventory`/`DUseInventoryMaster`, `LootSounds`, `DRenameItem`, `DTweqDevice`, `DDrunkPlayerTrap`, teleporters (`DTPBase`, `DPortal`, …) | +| `DScript File&Blob.nut` | Standalone `dfile` / `dblob` classes — read params out of files and `.str` resources. Backs the `>` operator | +| `DScript Overlays.nut` | `cDIngameLogOverlay` (in-game log), `cDHandlerFrameUpdater` (drives per-mid-frame updates), `cDWorldInvOverlay`. Picks Dark vs Shock overlay API | +| `DScript_ModdingTools.nut` | Editor-only: `DSpy`, `DAutoTxtRepl`, `DDumpModels`, `DEditorTrap`, `DTestTrap` (`DumpTable`), `DPerformanceTest` | +| `DSConfigDefault.nut` | **Read this first when changing behaviour.** All tunable consts, `eSeparator`, `eDQVarType` inputs, `MissionConstants`, and the `_dFROM` message-class patches | +| `DSConfigDefAutoTxt.nut` | Texture-replacement tables. Currently a **verbatim duplicate** of lines 119–228 of `DSConfigDefault.nut` | +| `DSConfigFix.nut` / `DSConfigMyFM.nut` | Per-mod / per-FM override stubs. Both declare `const kReplyMessage` | + +### Legacy — do NOT edit, do NOT copy patterns from + +`DScript.nut` (v0.42a monolith) · `DSEditorScripts.nut` (v0.1b) · `DT2UndercoverWeapons.nut` + +They redefine **~29 class names** that V2 also defines (`DBaseTrap`, `DRelayTrap`, `DHub`, `DRay`, +`DStdButton`, `DHudCompass`, `DPortal`, `DImUndercover`, …). See *Load order* below — they win. + +### Reference + +- `docs/DScript Documentation.pdf` — user manual, but for **v0.28a**. Roughly 40% of current + features are missing from it. Trust the code. +- `docs/userDefineLang_Squirrel DScript.xml` — Notepad++ syntax + fold definition. +- `backup/`, `obj/`, `strings/` — snapshots and DromEd assets, not build inputs. +- `docs/squirrel_script/` — **`squirrel.osm` engine API docs**, not DScript-specific. This is the + underlying Dark Engine/Squirrel binding that all of DScript is built on top of; consult it for + anything DScript's own docs don't cover, or to check what the engine itself provides vs. what + DScript adds: + - `ReadMe.txt` — how `squirrel.osm` works: script class basics (`extends SqRootScript`), message + handler naming (`On`, stim messages get a `Stimulus` suffix, non-alphanumeric names + need underscore substitution), `PreFilterMessage` global catch-all, `SetData`/`GetData` + persistence across script reconstruction, and how to write `IDarkOverlayHandler` / + `IShockOverlayHandler` overlay handlers (`DScript Overlays.nut` is DScript's wrapper around this). + - `API-reference.txt` — global functions, data types (`ObjID`, `cMultiParm`, `int_ref`/`float_ref` + out-params, etc.), and the `SqRootScript` base class members (timers, `Data`, `GetProperty`/ + `SetProperty`, links). Read this before assuming a primitive is DScript's rather than the engine's. + - `API-reference_services.txt` — the script service tables (`Object`, `Property`, `Link`, + `LinkTools`, `ActReact`, `Data`, `AI`, `Sound`, `Quest`, `Damage`, `Container`, `DarkGame`, + `DarkOverlay`, `ShockGame`/`ShockOverlay`/`ShockPsi`/`ShockAI`, etc.), called like + `Object.AddMetaProperty(self, "FrobInert")`. `#ifdef`-style comments mark Thief/SS2-only or + API-version-gated functions (cross-reference against `GetAPIVersion()` in `API-reference.txt`). + - `API-reference_messages.txt` — every specialized `sScr*Msg` class returned by `message()` + (e.g. `sScrTimerMsg.name`, `sDamageScrMsg`, `sQuestMsg`), each annotated with the message + name(s) it applies to. Needed whenever a handler reads extra fields off `message()` beyond + the generic `sScrMsg` base. + - `samples/T2_samples.nut`, `samples/SS2_samples.nut`, `samples/T2OverlaySample.nut` — small, + self-contained example scripts using the raw engine API (no DScript). Useful for seeing + idiomatic non-DScript squirrel before deciding whether a task needs a DScript class at all. + - `Notepad++/` — syntax/fold definitions for base `squirrel.osm` scripts, parallel to (but + separate from) `docs/userDefineLang_Squirrel DScript.xml` above. + +--- + +## Architecture + +### `DScript` — the library table (not a class) + +`DScript Core.nut:188`. Stateless helpers usable from anywhere: `GetAllDescendants`, +`FindClosestObjectInSet`, `ObjectsInPath`/`ObjectsInNet`, `GetModelDims`, `ScaleToMaxSize`, +`SetFacingForced`, `PolarCoordinates`/`RelativeAngles`, `DivideAtNext`, `GetQVar`/`SetQVar`/ +`DeleteQVar`, `CompileExpressions`. + +Its delegate uses `getstackinfos()` (`_GetInstance`, `Core:926`) so library functions can reach the +caller's `self` / `userparams()`. **Adding or removing a call frame in that path silently breaks +variable lookup** — the stack depths `5` and `7` are hard-coded in `_tempstore._get` (`Core:629`). + +### Class hierarchy + +``` +SqRootScript engine base +└── DBasics DCheckString, DGetParam[Raw], DPrint, D*TimerData + │ ↳ use directly for lightweight scripts (LootSounds, DUseInventoryMaster) + └── DBaseTrap message routing + Count/Capacitor/Delay/Repeat/FailChance/Condition + ├── DRelayTrap DSendMessage, DMultiMessage, DRelayMessages + │ ├── DTrigger adds a second, parallel "T"-prefixed parameter namespace + │ │ └── DHitScanTrap, DFocusOverTime, DDirector, DRenameItem, … + │ ├── DScriptHandler singleton, reachable as ::DHandler + │ ├── DHub per-message dispatcher (BROKEN at 0.81) + │ └── DStdButton + ├── DEditorScripts parent of everything in DScript_ModdingTools.nut + └── DWatchMe, DCopyPropertyTrap, DAddScript, DTPBase, DHudObject, DTweqDevice, … +``` + +**To write a new script:** `extend DBaseTrap` (or `DRelayTrap` if it sends messages, `DTrigger` if +it needs independent trigger-side timing) and override `DoOn(DN)` / `DoOff(DN)`. Everything else — +message matching, delays, counters, conditions — is inherited. + +### Message flow (`DBaseTrap.DBaseFunction`, `Core:1787`) + +``` +OnMessage → DBaseFunction → is msg in [Script]On/Off? … Stage 1–2 + → DCheckCondition … Stage 2X on fail + → DCheckParameters … Stage 3 capacitor, 4 count, 5 delay + → DoOn(DN) / DoOff(DN) … Stage 5/6 +``` + +Set `[ScriptName]Debug=1` in an object's Design Note to print those numbered stages to the monolog — +the fastest way to diagnose "my trap didn't fire". + +### `::DHandler` — the singleton + +A `Marker` named `DScriptHandler` is auto-created in the editor (`Core:2203`). It owns: +- `PerFrame_Register/DeRegister/ReRegister` — throttled updates (`Delay="3F"` = every 3 frames) +- `PerMidFrame_*` — every-frame updates, piggybacking the overlay `DrawHUD` callback +- `NewOverlay` / `EndOverlay`, `RegisterExternHandler` + +Registries are rebuilt on load from `SetData` keys, so they survive save/reload. + +### Globals + +`::DScript` (library table) · `::DHandler` (handler instance) · `::PlayerID` (cached, set on +`BeginScript`) · `::gGameOverlay` (Dark or Shock overlay) · `::gSHARED_SET` (scratch, `/` operator) · +`::GetPlayerArm` · `gDModTable` / `gDTexTable` · `getconsttable().MissionConstants` + +### Parameter convention + +Everything is read from the object's **Design Note** as `[ScriptName][On|Off]=value;…`. +`_script` on the instance holds the *effective* name — the class name, or `ClassName2…9` when the +`Copies` parameter is used, or the name with `T` appended while `DTrigger` is in trigger mode. +Always build parameter names as `_script + "Foo"`, never a hard-coded string. + +--- + +## Squirrel & NewDark good-to-knows + +- **`#` is a line comment.** The `## /-- §# … --\` banners are Notepad++ **fold markers**, not + decoration. Leave them alone. +- **`split()` drops empty tokens.** `split("]a]b", "]")` → `["a","b"]`, *not* `["","a","b"]`. +- **`find()` returns `null` when absent but `0` is a valid index.** Always test `== null`. `if (!x)` + is a bug — it already is one in `ObjectsInNet` (`Core:453`) and `ObjectsLinkedFromSet` (`Core:485`). +- **`Data.RandInt(low, high)` is inclusive** — `RandInt(0, arr.len())` overruns. +- **`<-` creates or silently overwrites a table slot**; `=` requires it to exist. Duplicate `<-` on + the root table does not error, so shadowing goes unnoticed. +- **Class member defaults that are tables/arrays are shared between instances.** Sometimes + deliberate (`LootSounds.TotalLoot`), usually a trap — null them in the constructor. +- **`::callee()` must be passed, not called.** `RepeatForCopies(::callee(), args…)`. Writing + `RepeatForCopies(::callee(args))` invokes it immediately → recursion. +- **Instances are destroyed and recreated on save/load.** Persistent state exists only in + `SetData/GetData`, QVars, and timer payloads. Carry multiple values across a delay with + `DSetTimerData(name, delay, …)` + `DGetTimerData(message().data)`. +- **A specific handler suppresses `OnMessage()`.** If you add `OnTimer` / `OnBeginScript` to a + subclass, call `base.OnTimer()` / `base.OnBeginScript()` or the framework stops receiving events. +- **Mutating an array inside its own `foreach` skips an element** — see the acknowledged bug in + `DFocusOverTime.PanToTarget` (`SFX:308`). +- `GetDarkGame()` → `0` = Thief 1/G, `1` = SS2, `2` = Thief 2. `IsEditor()` gates editor-only code — + several classes and `DBasics.constructor` itself only exist in the editor. +- Requires `GetAPIVersion() >= 11` (T2 v1.27 / SS2 v2.48). + +--- + +## Working in this repo + +### grep needs `-a` on two files + +`DScript Core.nut` and `DScript File&Blob.nut` are **ISO-8859-1**; grep classes them as binary and +returns *nothing* — no match, no warning, no error. + +```bash +grep -an "pattern" "DScript Core.nut" # -a is mandatory +grep -arn "pattern" --include="*.nut" . # for repo-wide sweeps +``` + +### Encodings are mixed and load-bearing + +Those two files are ANSI/Latin-1; the rest have drifted to UTF-8. `DScript Core.nut:1172` uses a +literal `§` as a `case` label in `DCheckString`, and `§`/`»` appear in fold markers throughout. +**Never bulk re-save, re-encode, or normalize line endings** (files are a mix of LF and CRLF too). +Use `Edit` with exact byte-matched strings; avoid rewriting whole files. + +### Filenames contain spaces and `&` + +Always quote: `"DScript File&Blob.nut"`. Unquoted `&` backgrounds the command. + +### Load order determines which code actually runs + +`squirrel.osm` compiles **every** `.nut` in `sq_scripts/` in filename order; later definitions win. +ASCII puts `"DScript "` (0x20) before `"DScript."` (0x2E), so **legacy `DScript.nut` loads after +`DScript Core/General/SFX.nut` and replaces their classes with v0.42a implementations.** Any +behavioural test of this branch is meaningless until the legacy files are moved out of the folder. + +Corollary for new work: your own `.nut` file must sort *after* the DScript core files to `extend` +its classes. + +### Reading the docs PDF + +`pdftotext`/poppler is not installed and the Read tool cannot render PDFs here: + +```bash +pip install pypdf +python3 -c "from pypdf import PdfReader; print('\n'.join(p.extract_text() for p in PdfReader('docs/DScript Documentation.pdf').pages))" +``` + +--- + +## Verification + +**Nothing in this repo can be run, built, linted, or tested locally.** Do not claim a change is +tested. Verification happens in DromEd: + +| Command | Purpose | +|---|---| +| `script_load squirrel` | Load the Squirrel module | +| `script_reload` | Recompile all `.nut` files — **also the only way to refresh Count/Capacitor data**; put it in `GameMode.cmd` | +| `script_test ` | Fire an `OnTest()` handler | +| `set dhelp` / `set dsnohello` | Help banner toggles (`dhelp` output is currently an empty stub) | +| `set deditor` | Makes `DEditorScripts` announce themselves, to catch editor-only scripts before shipping | + +Errors surface in `monolog.txt` (editor) or `Thief2.log` / `Shock2.log` (game). With +`kUseIngameLog = true` the tail of that log is drawn on screen in-game. + +--- + +## Known-broken at 0.81 — don't re-derive these + +**Full task list with file:line, cause and suggested fix: [`docs/OPEN_TASKS.md`](docs/OPEN_TASKS.md)** +(grouped, ID'd `T-nn`, ordered — start there rather than re-auditing). + +The headline items: + +- **T-01** Legacy files shadow V2 — nothing is testable until they leave the load path. +- **T-40** `DHub` is non-functional (the file header says so too). +- **T-10** `/` ping-back operator dies on an `intern`/`inter` typo. +- **T-20/T-21** `]` operator indexes a `split()` result wrongly; `==` conditions never match. +- **T-41/T-42** `DImUndercover` uses `|` where `&` was meant, so every mode always applies. +- **T-30** `DTrigQVar.CheckQuest` recurses unboundedly on any subscribed QVar change. +- **T-60** Unconditional `print()` in `DCheckString`, the hottest function in the framework — + strip before any performance measurement. From 40a9e0a77216de3a979e4ddaf01fe616665e3848 Mon Sep 17 00:00:00 2001 From: Daniel Sperber Date: Tue, 4 Aug 2026 20:49:00 +0000 Subject: [PATCH 2/9] docs: add and update custom API references --- CLAUDE.md | 37 +- DOC/squirrel_script/API-reference.txt | 1043 +++++++++++++++++ .../API-reference_messages.txt | 301 +++++ .../API-reference_services.txt | 743 ++++++++++++ DOC/squirrel_script/Custom-API-reference.nut | 4 +- .../Custom-API-reference_messages.nut | 2 +- .../Custom-API-reference_services.nut | 18 +- DOC/squirrel_script/ReadMe.txt | 335 ++++++ DOC/squirrel_script/samples/SS2_samples.nut | 861 ++++++++++++++ .../samples/T2OverlaySample.nut | 363 ++++++ DOC/squirrel_script/samples/T2_samples.nut | 402 +++++++ 11 files changed, 4086 insertions(+), 23 deletions(-) create mode 100644 DOC/squirrel_script/API-reference.txt create mode 100644 DOC/squirrel_script/API-reference_messages.txt create mode 100644 DOC/squirrel_script/API-reference_services.txt create mode 100644 DOC/squirrel_script/ReadMe.txt create mode 100644 DOC/squirrel_script/samples/SS2_samples.nut create mode 100644 DOC/squirrel_script/samples/T2OverlaySample.nut create mode 100644 DOC/squirrel_script/samples/T2_samples.nut diff --git a/CLAUDE.md b/CLAUDE.md index 27e97bc..fb25a32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,20 +18,23 @@ There is no build system, no package manager, no tests. The `.nut` files are dro |---|---| | `DScript Core.nut` | **The framework.** `DScript` library table, `DBasics`, `DBaseTrap`, `DRelayTrap`, `DTrigger`, `DScriptHandler`, `DHub`, QVar system (`DTrapSetQVar`, `DTrigQVar`, `DTrapDeleteQVar`) | | `DScript General.nut` | Gameplay traps: `DStdButton`, `DHitScanTrap`, `DWatchMe`, `DCopyPropertyTrap`, `DAddScript`, `DCompileTrap`, `DStackToQVar`, undercover scripts (`DImUndercover`, `DNotSuspAI*`, `DGoMissing`) | -| `DScript SFX.nut` | Visual/inventory/camera: `DRay`, `DArmAttachment`, `DFocusObject`, `DFocusOverTime`, `DDirector`, `DHudObject`, `DHudCompass`, `DInventoryMaster`/`DSubInventory`/`DUseInventoryMaster`, `LootSounds`, `DRenameItem`, `DTweqDevice`, `DDrunkPlayerTrap`, teleporters (`DTPBase`, `DPortal`, …) | +| `DScript SFX.nut` | Visual/inventory/camera: `DRay`, `DArmAttachment`, `DObjectFaceTarget`, `DObjectPanTo`, `DDirector`, `DHudObject`, `DHudCompass`, `DInventoryMaster`/`DSubInventory`/`DUseInventoryMaster`, `LootSounds`, `DRenameItem`, `DTweqDevice`, `DDrunkPlayerTrap`, teleporters (`DTPBase`, `DPortal`, …) | | `DScript File&Blob.nut` | Standalone `dfile` / `dblob` classes — read params out of files and `.str` resources. Backs the `>` operator | | `DScript Overlays.nut` | `cDIngameLogOverlay` (in-game log), `cDHandlerFrameUpdater` (drives per-mid-frame updates), `cDWorldInvOverlay`. Picks Dark vs Shock overlay API | | `DScript_ModdingTools.nut` | Editor-only: `DSpy`, `DAutoTxtRepl`, `DDumpModels`, `DEditorTrap`, `DTestTrap` (`DumpTable`), `DPerformanceTest` | | `DSConfigDefault.nut` | **Read this first when changing behaviour.** All tunable consts, `eSeparator`, `eDQVarType` inputs, `MissionConstants`, and the `_dFROM` message-class patches | -| `DSConfigDefAutoTxt.nut` | Texture-replacement tables. Currently a **verbatim duplicate** of lines 119–228 of `DSConfigDefault.nut` | +| `DSConfigDefAutoTxt.nut` | Texture-replacement tables. Currently a **verbatim duplicate** of lines 117–231 of `DSConfigDefault.nut` | | `DSConfigFix.nut` / `DSConfigMyFM.nut` | Per-mod / per-FM override stubs. Both declare `const kReplyMessage` | ### Legacy — do NOT edit, do NOT copy patterns from -`DScript.nut` (v0.42a monolith) · `DSEditorScripts.nut` (v0.1b) · `DT2UndercoverWeapons.nut` +`DT2UndercoverWeapons.nut` (defines `BlackJack`/`Sword`/`Arrow`; unrelated to V2, kept for reference). -They redefine **~29 class names** that V2 also defines (`DBaseTrap`, `DRelayTrap`, `DHub`, `DRay`, -`DStdButton`, `DHudCompass`, `DPortal`, `DImUndercover`, …). See *Load order* below — they win. +The v0.42a monolith `DScript.nut` and the v0.1b `DSEditorScripts.nut` — which used to redefine ~29 +V2 class names and win the load-order race described below — were **deleted by the upstream merge +that brought in the `Scripts-in-progress` history (2026-08-04)**. A repo-wide scan after that merge +found zero duplicate top-level class names among the root `.nut` files, so the load-order shadowing +problem tracked as `T-01` is resolved; `docs/OPEN_TASKS.md` has the details. ### Reference @@ -65,6 +68,15 @@ They redefine **~29 class names** that V2 also defines (`DBaseTrap`, `DRelayTrap idiomatic non-DScript squirrel before deciding whether a task needs a DScript class at all. - `Notepad++/` — syntax/fold definitions for base `squirrel.osm` scripts, parallel to (but separate from) `docs/userDefineLang_Squirrel DScript.xml` above. + - `Custom-API-reference.nut`, `Custom-API-reference_messages.nut`, `Custom-API-reference_services.nut` + — hand-improved rewrites of the three `.txt` files above (saved as `.nut` purely so editors + syntax-highlight them; they are still plain reference text, not runnable scripts). **Prefer + these over the `.txt` originals** — each is a strict superset (explicit enum/flag numeric + values, cross-references from enums to the services/messages that use them, expanded prose on + `SqRootScript` semantics). An audit (2026-08-04) found and fixed a handful of transcription + defects (bad arithmetic in a `KEY_PGDN` comment, a stray enum comma, an unfinished cross-ref + note, dropped quotes around several string-literal defaults, one `#ifNOT`/`#ifndef` typo) — + all corrected, nothing outstanding to watch for. --- @@ -90,7 +102,7 @@ SqRootScript engine base └── DBaseTrap message routing + Count/Capacitor/Delay/Repeat/FailChance/Condition ├── DRelayTrap DSendMessage, DMultiMessage, DRelayMessages │ ├── DTrigger adds a second, parallel "T"-prefixed parameter namespace - │ │ └── DHitScanTrap, DFocusOverTime, DDirector, DRenameItem, … + │ │ └── DHitScanTrap, DObjectPanTo, DDirector, DRenameItem, … │ ├── DScriptHandler singleton, reachable as ::DHandler │ ├── DHub per-message dispatcher (BROKEN at 0.81) │ └── DStdButton @@ -158,7 +170,7 @@ Always build parameter names as `_script + "Foo"`, never a hard-coded string. - **A specific handler suppresses `OnMessage()`.** If you add `OnTimer` / `OnBeginScript` to a subclass, call `base.OnTimer()` / `base.OnBeginScript()` or the framework stops receiving events. - **Mutating an array inside its own `foreach` skips an element** — see the acknowledged bug in - `DFocusOverTime.PanToTarget` (`SFX:308`). + `DObjectPanTo.PanToTarget` (`SFX:308`). - `GetDarkGame()` → `0` = Thief 1/G, `1` = SS2, `2` = Thief 2. `IsEditor()` gates editor-only code — several classes and `DBasics.constructor` itself only exist in the editor. - Requires `GetAPIVersion() >= 11` (T2 v1.27 / SS2 v2.48). @@ -191,9 +203,11 @@ Always quote: `"DScript File&Blob.nut"`. Unquoted `&` backgrounds the command. ### Load order determines which code actually runs `squirrel.osm` compiles **every** `.nut` in `sq_scripts/` in filename order; later definitions win. -ASCII puts `"DScript "` (0x20) before `"DScript."` (0x2E), so **legacy `DScript.nut` loads after -`DScript Core/General/SFX.nut` and replaces their classes with v0.42a implementations.** Any -behavioural test of this branch is meaningless until the legacy files are moved out of the folder. +ASCII puts `"DScript "` (0x20) before `"DScript."` (0x2E), which used to matter because the legacy +`DScript.nut` monolith sorted after `DScript Core/General/SFX.nut` and replaced their classes with +v0.42a implementations. That file (and `DSEditorScripts.nut`) is gone as of the 2026-08-04 merge — +see T-01 in `docs/OPEN_TASKS.md` — but the sort-order mechanism itself is still live and still worth +knowing before adding new files. Corollary for new work: your own `.nut` file must sort *after* the DScript core files to `extend` its classes. @@ -234,7 +248,8 @@ Errors surface in `monolog.txt` (editor) or `Thief2.log` / `Shock2.log` (game). The headline items: -- **T-01** Legacy files shadow V2 — nothing is testable until they leave the load path. +- **T-01** ~~Legacy files shadow V2~~ — resolved by the 2026-08-04 merge (`DScript.nut` / + `DSEditorScripts.nut` deleted upstream); see `docs/OPEN_TASKS.md` for the remaining detail. - **T-40** `DHub` is non-functional (the file header says so too). - **T-10** `/` ping-back operator dies on an `intern`/`inter` typo. - **T-20/T-21** `]` operator indexes a `split()` result wrongly; `==` conditions never match. diff --git a/DOC/squirrel_script/API-reference.txt b/DOC/squirrel_script/API-reference.txt new file mode 100644 index 0000000..604ea40 --- /dev/null +++ b/DOC/squirrel_script/API-reference.txt @@ -0,0 +1,1043 @@ +SQUIRREL.OSM API Reference +========================== + +This reference uses pseudo C declarations to show what data types functions return or expect as arguments. +Functions that have no return value do however not use 'void' as in C, they simply omit the return type. + +Message classes are documented separately in "API-reference_messages.txt". +Script services are documented separately in "API-reference_services.txt". + +Functions available through the Squirrel standard libs are not covered here. + + +// ---------------------------------------------------------------- +// DATA TYPES +// ---------------------------------------------------------------- + +int : an integer value +uint : an integer value (unsigned integer natively but squirrel doesn't have an unsigned type) +float : a floating point value +bool : a boolean 'true' or 'false' value +BOOL : an integer value (constants 'TRUE' or 'FALSE', but can be interchanged with 1 or 0) +HRESULT : an integer value used to indicate the result of many script service functions (>= 0 means success, typically S_OK, < 0 means failure) +ObjID : an integer representing an object ID +LinkID : an integer representing a link ID +RelationID : an integer representing a link flavor / relation ID +StimID : an integer representing an ObjID to a stimulus archetype +StimSensorID : an integer representing a LinkID for stim sensor +StimSourceID : an integer representing a LinkID for stim source +timer_handle : an integer representing a script timer handle +cMultiParm : a dynamically typed value, can be an int, float, string, vector or 'null' (use squirrel type checking to determine type if necessary) +sqtable : a squirrel table object +sqblob : a squirrel blob object (a chunk of binary data) + +object : when seen as a "object" function argument then it expects an ObjID or an object name string + when seen as a "object &" function argument then it expects an 'object' object to return an ObjID in (like "int_ref" below, and like + int_ref "tointeger()" is used as well to access the returned ObjID) + +stimulus_kind: same as "object" but for StimID + +string : a string value, in the majority of cases just a regular squirrel string value, but when seen as a "string &" function + argument like Engine.ConfigGetRaw() then the function expects a "string()" object in which it will return a string, + example script use: + local str = string(); + if ( Engine.ConfigGetRaw("somevar", str) ) + print("the config var was: " + str); + +vector : a 'vector' object, in rare cases script service functions take a "vector &" argument, in those cases the function expects a vector + object that it will fill with a return value (like "string" above) + +int_ref : an integer reference value, used as arguments in some script service functions where the function returns a value in the argument + for example Engine.ConfigGetInt(), where the second argument is an int_ref that will contain the config var's value upon a successful + return of the function, example script use: + local iref = int_ref(); + if ( Engine.ConfigGetInt("somevar", iref) ) + ... = iref.tointeger(); + +float_ref : a float reference value, used as arguments in some script service functions where the function returns a value in the argument + example script use: + local fref = float_ref(); + if ( Engine.ConfigGetFlat("somevar", fref) ) + ... = fref.tofloat(); + + +// ---------------------------------------------------------------- +// GLOBAL FUNCTIONS +// ---------------------------------------------------------------- + +// returns the Dark engine API version +// 0 = old dark T1/TG +// 1 = old dark SS2 +// 2 = old dark T2 +// 3 = NewDark T2 v1.19 / SS2 v2.4 +// 4 = NewDark T2 v1.20 / SS2 v2.41 +// 5 = NewDark T2 v1.21 / SS2 v2.42 +// 6 = NewDark T2 v1.22 / SS2 v2.43 +// 7 = NewDark T2 v1.23 / SS2 v2.44 +// 8 = NewDark T2 v1.24 / SS2 v2.45 +// 9 = NewDark T2 v1.25 / SS2 v2.46 +// 10 = NewDark T2 v1.26 / SS2 v2.47 +// 11 = NewDark T2 v1.27 / SS2 v2.48 +// etc. +int GetAPIVersion(); + +// returns the type of game +// 0 = T1/TG +// 1 = SS2 +// 2 = T2 +int GetDarkGame(); + +// returns non-zero if the host application is the editor +int IsEditor(); + + +// construct a vector object with its components set to 0 +vector vector(); +// construct a vector object with its components set to 'f' +vector vector(float f); +// construct a vector object with its components set to X Y Z +vector vector(float X, float Y, float Z); + +// construct zero-initialized link descriptor +sLink sLink(); +// construct a link descriptor initialized by the link 'lid' +sLink sLink(LinkID link); + +// The use of 'object' objects is very limited. For the rare cases where a script service function has an +// "object &" param (like Physics.GetClimbingObject) or when needing to set a cMultiParm explicitly to an +// object ID type instead of a regular integer type. +// +// construct an 'object' initialized to 0 +object object(); +// construct an 'object' object initialized with an object ID +object object(ObjID obj); + +// construct an int reference object, for use as function argument in script service functions that have int_ref arguments +int_ref int_ref(); +int_ref int_ref(int i); + +// construct a float reference object, for use as function argument in script service functions that have float_ref arguments +float_ref float_ref(); +float_ref float_ref(float f); + +// construct a string object, for use as function argument in script service functions that have "string &" arguments +string string(); +string string(string s); + + +// ---------------------------------------------------------------- +// CLASSES +// ---------------------------------------------------------------- + +// a 3D vector +class vector +{ + float x; + float y; + float z; + + // Arithmetic operators: + // + // - vector : negated components + // vector + vector : component-wise addition + // vector - vector : component-wise subtraction + // vector * vector : component-wise multiplication + // vector / vector : component-wise division + // vector + float : component-wise addition of a scalar value + // vector - float : component-wise subtraction of a scalar value + // vector * float : component-wise multiplication of a scalar value + // vector / float : component-wise division of a scalar value + + // scale this vector by 'f' + Scale(float f); + + // return the dot product between this vector and 'v' + float Dot(vector v); + // return the cross product between this vector and 'v' (this x v) + vector Cross(vector v); + // return the length of this vector + float Length(); + // normalize this vector + Normalize(); + // return a normalized version of this vector + vector GetNormalized(); +} + +// a link descriptor +class sLink +{ + ObjID source; + ObjID dest; + RelationID flavor; + + // re-initialize this descriptor with the link 'lid', returns TRUE if link ID was valid and descriptor was updated + BOOL LinkGet(LinkID link); + + // for API compatibility with old OSM code these accessor functions are provided as well, + // but there is no benefit in using these over accessing 'source', 'dest' and 'flavor' directly + ObjID From(); + ObjID To(); + RelationID Kind(); +} + +// an iteratable set of links, preferably used in a 'foreach' statement, like "foreach (l in Link.GetAll(flavor, from, to))" +class linkset +{ + // if for some reason 'foreach' isn't desired then iteration can also be handled with the following functions, + // but don't mix the use of these and 'foreach' + // while ( ls.AnyLinksLeft() ) + // { + // l = ls.Link(); + // ls.NextLink(); + // } + BOOL AnyLinksLeft(); + LinkID Link(); + NextLink(); +} + +// base class for all scripts +class SqRootScript +{ + // ObjID of the object this script is attached to + const ObjID self; + + // returns the class name of this script + string GetClassName(); + + // functions that are only to be used from within message handler functions + // + // return the current message, which can be a sScrMsg or any derived message class, depending on message type + sScrMsg_or_derived message(); + // does a case insensitive compare of 'message().message' and 'sMessageName' + BOOL MessageIs(string sMessageName); + // set the kSMF_MsgBlock flag on the current message + BlockMessage(); + // set message reply + Reply(cMultiParm value); + // set message reply to an ObjID (could also be done with "Reply(object(id))", but this is easier and more efficient) + ReplyWithObj(object value); + + // When sending/posting messages do not use message names of built-in specialized message types (see message reference), + // because those names are reserved for specialized message classes. SendMessage/PostMessage will however only generate + // a regular sScrMsg message. Using the name of a specialized message type will cause undefined behavior or instability. + // + // Also keep in mind that script message handlers are squirrel functions, so the name of the message (with an "On" prefix) + // should preferably be a valid identifier (only alpha numeric characters and underscore). It's still possible to declare + // a handler for messages with other characters by replacing the characters with underscore in the handler functions name, + // or handle the message through the generic OnMessage() handler which is less efficient and messier. + + // send an immediate message + cMultiParm SendMessage(object to, string sMessage, cMultiParm data, cMultiParm data2, cMultiParm data3); + cMultiParm SendMessage(object to, string sMessage, cMultiParm data, cMultiParm data2); + cMultiParm SendMessage(object to, string sMessage, cMultiParm data); + cMultiParm SendMessage(object to, string sMessage); + + // post a message on the message queue + PostMessage(object to, string sMessage, cMultiParm data, cMultiParm data2, cMultiParm data3); + PostMessage(object to, string sMessage, cMultiParm data, cMultiParm data2); + PostMessage(object to, string sMessage, cMultiParm data); + PostMessage(object to, string sMessage); + + // set a timer on 'self', returns a timer handle that can be used with KillTimer + timer_handle SetOneShotTimer(string sTimerName, float fPeriod, cMultiParm data); + timer_handle SetOneShotTimer(string sTimerName, float fPeriod); + + // set a timer on 'to', returns a timer handle that can be used with KillTimer + timer_handle SetOneShotTimer(ObjID to, string sTimerName, float fPeriod, cMultiParm data); + timer_handle SetOneShotTimer(ObjID to, string sTimerName, float fPeriod); + + // remove a timer that has not yet fired + KillTimer(timer_handle th); + + // returns the time (in seconds) of the currently processed message if inside a message handler, + // otherwise the time of the last processed message + float GetTime(); + + // easy-access object property functions that operate on 'self' (without having to use the Property script service) + BOOL HasProperty(string sPropName); + cMultiParm GetProperty(string sPropName, string sFieldName); + cMultiParm GetProperty(string sPropName); + BOOL SetProperty(string sPropName, string sFieldName, cMultiParm value); + BOOL SetProperty(string sPropName, cMultiParm value); + + // functions for persistent script data/vars + BOOL IsDataSet(string sVarName); + cMultiParm GetData(string sVarName); + cMultiParm SetData(string sVarName, cMultiParm value); + cMultiParm SetData(string sVarName); // same as SetData(sVarName, null) + cMultiParm ClearData(string sVarName); + + // returns an ObjID based on an object name + ObjID ObjID(string sObjName); + // returns a link flavor based on a link flavor/type name + RelationID linkkind(string sLinkFlavorName); + // returns the destination of a link (for quick access without using an sLink object) + ObjID LinkDest(LinkID id); + + // returns a squirrel table object that contains the parsed key/value pairs of the "Design Note" property on object 'self' + // (if the host application is old dark SS2 then the "Objlist Arg" property is used instead) + // The parsing is based on semi-colon separated "key=value" syntax, the parsing works the same as in darkdlgs when clicking + // with the middle mouse button in the "Design Note" property dialog. + // If the parser detects that a value matches integer or floating point value syntax then it generates squirrel values of those + // types, in all other cases the values are treated as string values. + // The returned squirrel table contains one slot for each key. Note that squirrel syntax dictates that key names are valid + // identifiers, the table will only contain 'key' entries that fulfill that requirement. If the "Design Note" property contains + // key names that aren't valid identifiers then those are ignored. + // + // The parsed object property is cached the first time userparams() is called, if the property is subsequently changed then + // those changes won't be reflected by userparams(). + // + // Ex: + // object property contains: MyScriptParam1=123; MyScriptParam2="abc" + // script code can access those as: userparams().MyScriptParam1 and userparams().MyScriptParam2 + sqtable userparams(); +} + + +// ---------------------------------------------------------------- +// COMMON CONSTANTS +// ---------------------------------------------------------------- + +TRUE +FALSE +S_OK + +ANIM_LIGHT_MODE_FLIP +ANIM_LIGHT_MODE_SMOOTH +ANIM_LIGHT_MODE_RANDOM +ANIM_LIGHT_MODE_MINIMUM +ANIM_LIGHT_MODE_MAXIMUM +ANIM_LIGHT_MODE_EXTINGUISH +ANIM_LIGHT_MODE_SMOOTH_BRIGHTEN +ANIM_LIGHT_MODE_SMOOTH_DIM +ANIM_LIGHT_MODE_RAND_COHERENT +ANIM_LIGHT_MODE_FLICKER + +AMBFLG_S_ENVIRON +AMBFLG_S_NOSHARPCURVE +AMBFLG_S_TURNEDOFF + +AMBFLG_S_REMOVE +AMBFLG_S_MUSIC +AMBFLG_S_SYNCH +AMBFLG_S_NOFADE +AMBFLG_S_KILLOBJ +AMBFLG_S_AUTOOFF + +TWEQ_LIMIT_RATE +TWEQ_LIMIT_LOW +TWEQ_LIMIT_HIGH + +TWEQ_AC_NOLIMIT +TWEQ_AC_SIM +TWEQ_AC_WRAP +TWEQ_AC_1BOUNCE +TWEQ_AC_SIMRADSM +TWEQ_AC_SIMRADLG +TWEQ_AC_OFFSCRN + +TWEQ_AS_ONOFF +TWEQ_AS_REVERSE +TWEQ_AS_RESYNCH +TWEQ_AS_GOEDGE +TWEQ_AS_LAPONE + +TWEQ_HALT_KILL +TWEQ_HALT_REM +TWEQ_HALT_STOP +TWEQ_STATUS_QUO +TWEQ_HALT_SLAY +TWEQ_FRAME_EVENT + +TWEQ_CC_JITTER +TWEQ_CC_MUL +TWEQ_CC_PENDULUM +TWEQ_CC_BOUNCE + +TWEQ_MC_ANCHOR +TWEQ_MC_SCRIPTS +TWEQ_MC_RANDOM +TWEQ_MC_GRAV +TWEQ_MC_ZEROVEL +TWEQ_MC_TELLAI +TWEQ_MC_PUSHOUT +TWEQ_MC_NEGLOGIC +TWEQ_MC_RELVEL +TWEQ_MC_NOPHYS +TWEQ_MC_VHOT +TWEQ_MC_HOSTONLY +TWEQ_MC_CREATSCL +TWEQ_MC_USEM5 +TWEQ_MC_LINKREL + +TRAPF_NONE +TRAPF_ONCE +TRAPF_INVERT +TRAPF_NOON +TRAPF_NOOFF + +enum ePlayerMode +{ + kPM_Stand + kPM_Crouch + kPM_Swim + kPM_Climb + kPM_BodyCarry + kPM_Slide + kPM_Jump + kPM_Dead +} + +enum eScrMsgFlags +{ + kSMF_MsgSent + kSMF_MsgBlock + kSMF_MsgSendToProxy + kSMF_MsgPostToOwner +} + +enum eScrTimedMsgKind +{ + kSTM_OneShot + kSTM_Periodic +} + +enum eKeyUse +{ + kKeyUseDefault + kKeyUseOpen + kKeyUseClose + kKeyUseCheck +} + +enum eAIActionPriority +{ + kLowPriorityAction + kNormalPriorityAction + kHighPriorityAction +} + +enum eAIScriptAlertLevel +{ + kNoAlert + kLowAlert + kModerateAlert + kHighAlert +} + +enum eAIScriptSpeed +{ + kSlow + kNormalSpeed + kFast +} + +enum eAITeam +{ + kAIT_Good + kAIT_Neutral + kAIT_Bad1 + kAIT_Bad2 + kAIT_Bad3 + kAIT_Bad4 + kAIT_Bad5 +} + +enum eAIMode +{ + kAIM_Asleep + kAIM_SuperEfficient + kAIM_Efficient + kAIM_Normal + kAIM_Combat + kAIM_Dead +} + +enum eAIActionResult +{ + kActionDone + kActionFailed + kActionNotAttempted +} + +enum eAIAction +{ + kAINoAction + + kAIGoto + kAIFrob + kAIManeuver +} + +enum eBodyAction +{ + kMotionStart + kMotionEnd + kMotionFlagReached +} + +enum eDoorAction +{ + kOpen + kClose + kOpening + kClosing + kHalt +} + +enum eDoorStatus +{ + kDoorClosed + kDoorOpen + kDoorClosing + kDoorOpening + kDoorHalt + + kDoorNoDoor +} + +enum ePhysScriptMsgType +{ + kNoMsg + + kCollisionMsg + kContactMsg + kEnterExitMsg + kFellAsleepMsg + kWokeUpMsg + + kMadePhysMsg + kMadeNonPhysMsg +} + +enum ePhysMessageResult +{ + kPM_StatusQuo + kPM_Nothing + kPM_Bounce + kPM_Slay + kPM_NonPhys +} + +enum ePhysCollisionType +{ + kCollNone + kCollTerrain + kCollObject +} + +enum ePhysContactType +{ + kContactNone + kContactFace + kContactEdge + kContactVertex + kContactSphere + kContactSphereHat + kContactOBB +} + +enum eRoomChange +{ + kEnter + kExit + kRoomTransit +} + +enum eObjType +{ + kPlayer + kRemotePlayer + kCreature + kObject + kNull +} + +enum eSlayResult +{ + kSlayNormal + kSlayNoEffect + kSlayTerminate + kSlayDestroy +} + +enum eTweqType +{ + kTweqTypeScale + kTweqTypeRotate + kTweqTypeJoints + kTweqTypeModels + kTweqTypeDelete + kTweqTypeEmitter + kTweqTypeFlicker + kTweqTypeLock + kTweqTypeAll + kTweqTypeNull +} + +enum eTweqDirection +{ + kTweqDirForward + kTweqDirReverse +} + +enum eTweqOperation +{ + kTweqOpKillAll + kTweqOpRemoveTweq + kTweqOpHaltTweq + kTweqOpStatusQuo + kTweqOpSlayAll + kTweqOpFrameEvent +} + +enum eTweqDo +{ + kTweqDoDefault + kTweqDoActivate + kTweqDoHalt + kTweqDoReset + kTweqDoContinue + kTweqDoForward + kTweqDoReverse +} + +enum eQuestDataType +{ + kQuestDataMission + kQuestDataCampaign + kQuestDataUnknown +} + +enum eSoundSpecial +{ + kSoundNormal + kSoundLoop +} + +enum eEnvSoundLoc +{ + kEnvSoundOnObj + kEnvSoundAtObjLoc + kEnvSoundAmbient +} + +enum eSoundNetwork +{ + kSoundNetDefault + kSoundNetworkAmbient + kSoundNoNetworkSpatial +} + +enum eFrobLoc +{ + kFrobLocWorld + kFrobLocInv + kFrobLocTool + kFrobLocNone +} + +enum eContainsEvent +{ + kContainQueryAdd + kContainQueryCombine + + kContainAdd + kContainRemove + kContainCombine +} + +DEFAULT_TIMEOUT + +ECONTAIN_NULL + +CTF_NONE +CTF_COMBINE + +KB_FLAG_DOWN +KB_FLAG_CTRL +KB_FLAG_ALT +KB_FLAG_SPECIAL +KB_FLAG_SHIFT +KB_FLAG_2ND + +KEY_BS +KEY_TAB +KEY_ENTER +KEY_ESC +KEY_SPACE +KEY_F1 +KEY_F2 +KEY_F3 +KEY_F4 +KEY_F5 +KEY_F6 +KEY_F7 +KEY_F8 +KEY_F9 +KEY_F10 +KEY_F11 +KEY_F12 +KEY_INS +KEY_DEL +KEY_HOME +KEY_END +KEY_PGUP +KEY_PGDN +KEY_LEFT +KEY_RIGHT +KEY_UP +KEY_DOWN +KEY_GREY_SLASH +KEY_GREY_STAR +KEY_GREY_PLUS +KEY_GREY_MINUS +KEY_GREY_ENTER +KEY_PAD_HOME +KEY_PAD_UP +KEY_PAD_PGUP +KEY_PAD_LEFT +KEY_PAD_CENTER +KEY_PAD_RIGHT +KEY_PAD_END +KEY_PAD_DOWN +KEY_PAD_PGDN +KEY_PAD_INS +KEY_PAD_DEL + + +// ---------------------------------------------------------------- +// THIEF CONSTANTS +// ---------------------------------------------------------------- + +enum eDrkInvCap +{ + kDrkInvCapCycle + kDrkInvCapWorldFrob + kDrkInvCapWorldFocus + kDrkInvCapInvFrob +} + +enum eDrkInvControl +{ + kDrkInvControlOn + kDrkInvControlOff + kDrkInvControlToggle +} + +enum StyleColorKind +{ + StyleColorFG + StyleColorBG + StyleColorText + StyleColorHilite + StyleColorBright + StyleColorDim + StyleColorFG2 + StyleColorBG2 + StyleColorBorder + StyleColorWhite + StyleColorBlack + StyleColorXOR + StyleColorBevelLight + StyleColorBevelDark +} + +enum eGoalState +{ + kGoalIncomplete + kGoalComplete + kGoalInactive + kGoalFailed +} + +enum eDarkWeaponType +{ + kDWT_Sword + kDWT_BlackJack +} + +enum eWhichInvObj +{ + kCurrentWeapon + kCurrentItem +} + +enum eInventoryType +{ + kInvTypeJunk + kInvTypeItem + kInvTypeWeapon +} + +enum eDarkContainType +{ + kContainTypeAlt + kContainTypeHand + kContainTypeBelt + kContainTypeRendMin + kContainTypeRendMax + + kContainTypeNonRendMin + kContainTypeGeneric + kContainTypeInventory + kContainTypeNonRendMax + + kContainTypeMin + kContainTypeMax +} + + +// ---------------------------------------------------------------- +// SS2 CONSTANTS +// ---------------------------------------------------------------- + +enum eStats +{ + kStatStrength + kStatEndurance + kStatPsi + kStatAgility + kStatCyber +} + +enum eWeaponSkills +{ + kWeaponConventional + kWeaponEnergy + kWeaponHeavy + kWeaponAnnelid + kWeaponPsiAmp +} + +enum eTechSkills +{ + kTechHacking + kTechRepair + kTechModify + kTechMaintenance + kTechResearch +} + +enum ePsiPowers +{ + kPsiLevel1 + kPsiPsiScreen + kPsiStillHand + kPsiPull + kPsiQuickness + kPsiCyber + kPsiCryokinesis + kPsiCodebreaker + + kPsiLevel2 + kPsiStability + kPsiBerserk + kPsiRadShield + kPsiHeal + kPsiMight + kPsiPsi + kPsiImmolate + + kPsiLevel3 + kPsiFabricate + kPsiElectro + kPsiAntiPsi + kPsiToxinShield + kPsiRadar + kPsiPyrokinesis + kPsiTerror + + kPsiLevel4 + kPsiInvisibility + kPsiSeeker + kPsiDampen + kPsiVitality + kPsiAlchemy + kPsiCyberHack + kPsiSword + + kPsiLevel5 + kPsiMajorHealing + kPsiSomaDrain + kPsiTeleport + kPsiEnrage + kPsiForceWall + kPsiMines + kPsiShield + + kPsiNone +} + +enum ePsiPowerType +{ + kPsiTypeShot + kPsiTypeShield + kPsiTypeOneShot + kPsiTypeSustained + kPsiTypeCursor +} + +enum ePlayerEquip +{ + kEquipWeapon + kEquipWeaponAlt + kEquipArmor + kEquipSpecial + kEquipSpecial2 + + kEquipPDA + kEquipHack + kEquipModify + kEquipRepair + kEquipResearch + + kEquipFakeNanites + kEquipFakeCookies + kEquipFakeLogs + kEquipFakeKeys + + kEquipCompass +} + +enum eEcoState +{ + kEcologyNormal + kEcologyHacked + kEcologyAlert +} + +enum eSpawnFlags +{ + kSpawnFlagNone + kSpawnFlagPopLimit + kSpawnFlagPlayerDist + kSpawnFlagGotoAlarm + kSpawnFlagSelfMarker + kSpawnFlagRaycast + kSpawnFlagFarthest + + kSpawnFlagDefault + kSpawnFlagAll +} + +enum eImplant +{ + kImplantStrength + kImplantEndurance + kImplantAgility + kImplantPsi + kImplantMaxHP + kImplantRun + kImplantAim + kImplantTech + kImplantResearch + kImplantWormMind + kImplantWormBlood + kImplantWormBlend + kImplantWormHeart +} + +enum eTrait +{ + kTraitEmpty + + kTraitMetabolism + kTraitPharmo + kTraitPackRat + kTraitSpeedy + kTraitSharpshooter + + kTraitAble + kTraitCybernetic + kTraitTank + kTraitLethal + kTraitSecurity + + kTraitSmasher + kTraitBorg + kTraitReplicator + kTraitPsionic + kTraitTinker + + kTraitAutomap +} + +NUM_TRAIT_SLOTS + +enum eObjState +{ + kObjStateNormal + kObjStateBroken + kObjStateDestroyed + kObjStateUnresearched + kObjStateLocked + kObjStateHacked +} + +kOverlayInv +kOverlayFrame +kOverlayText +kOverlayRep +kOverlayBook +kOverlayComm +kOverlayContainer +kOverlayHRM +kOverlayRadar +kOverlayLetterbox +kOverlayOverload +kOverlayPsi +kOverlayYorN +kOverlayKeypad +kOverlayLook +kOverlayAmmo +kOverlayMeters +kOverlayHUD +kOverlayStats +kOverlaySkills +kOverlayBuyTraits +kOverlaySetting +kOverlayCrosshair +kOverlayResearch +kOverlayPDA +kOverlayEmail +kOverlayMap +kOverlayAlarm +kOverlayPsiIcons +kOverlayHackIcon +kOverlayRadiation +kOverlayPoison +kOverlayMiniFrame +kOverlaySecurity +kOverlayTicker +kOverlayBuyStats +kOverlayBuyTech +kOverlayBuyWeapon +kOverlayBuyPsi +kOverlayTechSkill +kOverlayMFDGame +kOverlayTlucText +kOverlaySecurComp +kOverlayHackComp +kOverlayHRMPlug +kOverlayMiniMap +kOverlayElevator +kOverlayVersion +kOverlayTurret +kOverlayMouseMode + +kOverlayModeOff +kOverlayModeOn +kOverlayModeToggle + +DEFAULT_MSG_TIME + +SCM_NORMAL +SCM_DRAGOBJ +SCM_USEOBJ +SCM_LOOK +SCM_PSI +SCM_SPLIT + +MAX_STAT_VAL +MAX_SKILL_VAL diff --git a/DOC/squirrel_script/API-reference_messages.txt b/DOC/squirrel_script/API-reference_messages.txt new file mode 100644 index 0000000..38a27d2 --- /dev/null +++ b/DOC/squirrel_script/API-reference_messages.txt @@ -0,0 +1,301 @@ +The type of class returned by the function "message()", that can be called in message handlers to access +the current message, depends on the message. Here is a listing of all the different message classes. +Each message class has a list for which messages it's relevant. The generic message class "sScrMsg" is +used for all other messages that aren't listed here. + +// generic message and base class for all specialized message types +// (specialized messages have additional message data specific to that message type) +class sScrMsg +{ + const ObjID from; + const ObjID to; + const string message; + const uint time; + const int flags; + const cMultiParm data; + const cMultiParm data2; + const cMultiParm data3; +} + + +// ---------------------------------------------------------------- +// COMMON SPECIALIZED MESSSAGES +// ---------------------------------------------------------------- + +// Messages: "Timer" +class sScrTimerMsg extends sScrMsg +{ + const string name; +} + +// Messages: "TweqComplete" +class sTweqMsg extends sScrMsg +{ + const eTweqType Type; + const eTweqOperation Op; + const eTweqDirection Dir; +} + +// Messages: "SoundDone" +class sSoundDoneMsg extends sScrMsg +{ + const vector coordinates; + const ObjID targetObject; + const string name; +} + +// Messages: "SchemaDone" +class sSchemaDoneMsg extends sScrMsg +{ + const vector coordinates; + const ObjID targetObject; + const string name; +} + +// Messages: "Sim" +class sSimMsg extends sScrMsg +{ + const BOOL starting; +} + +// Messages: "ObjRoomTransit", "PlayerRoomEnter", "PlayerRoomExit", "RemotePlayerRoomEnter", "RemotePlayerRoomExit", +// "CreatureRoomEnter", "CreatureRoomExit", "ObjectRoomEnter", "ObjectRoomExit" +class sRoomMsg extends sScrMsg +{ + const ObjID FromObjId; + const ObjID ToObjId; + const ObjID MoveObjId; + const eObjType ObjType; + const eRoomChange TransitionType; +} + +// Messages: "QuestChange" +class sQuestMsg extends sScrMsg +{ + const string m_pName; + const int m_oldValue; + const int m_newValue; +} + +// Messages: "MovingTerrainWaypoint" +class sMovingTerrainMsg extends sScrMsg +{ + const ObjID waypoint; +} + +// Messages: "WaypointReached" +class sWaypointMsg extends sScrMsg +{ + const ObjID moving_terrain; +} + +// Messages: "MediumTransition" +class sMediumTransMsg extends sScrMsg +{ + const int nFromType; + const int nToType; +} + +// Messages: "FrobToolBegin", "FrobToolEnd", "FrobWorldBegin", "FrobWorldEnd", "FrobInvBegin", "FrobInvEnd" +class sFrobMsg extends sScrMsg +{ + const ObjID SrcObjId; + const ObjID DstObjId; + const ObjID Frobber; + const eFrobLoc SrcLoc; + const eFrobLoc DstLoc; + const float Sec; + const BOOL Abort; +} + +// Messages: "DoorOpen", "DoorClose", "DoorOpening", "DoorClosing", "DoorHalt" +class sDoorMsg extends sScrMsg +{ + const eDoorAction ActionType; + const eDoorAction PrevActionType; + const BOOL isProxy; +} + +// Messages: "Difficulty" +class sDiffScrMsg extends sScrMsg +{ + const int difficulty; +} + +// Messages: "Damage" +class sDamageScrMsg extends sScrMsg +{ + const int kind; + const int damage; + const ObjID culprit; +} + +// Messages: "Slain" +class sSlayMsg extends sScrMsg +{ + const ObjID culprit; + const int kind; +} + +// Messages: "Container" +class sContainerScrMsg extends sScrMsg +{ + const eContainsEvent event; + const ObjID containee; +} + +// Messages: "Contained" +class sContainedScrMsg extends sScrMsg +{ + const eContainsEvent event; + const ObjID container; +} + +// Messages: "Combine" +class sCombineScrMsg extends sScrMsg +{ + const ObjID combiner; +} + +// Messages: "ContainSimActivate", "ContainAdd", "ContainRemove", "ContainCombine" +class sContainMsg extends sScrMsg +{ + const ObjID container; + const ObjID containee; +} + +// Messages: "MotionStart", "MotionEnd", "MotionFlagReached" +class sBodyMsg extends sScrMsg +{ + const eBodyAction ActionType; + const string MotionName; + const int FlagValue; +} + +// Messages: "StartWindup", "StartAttack", "EndAttack" +class sAttackMsg extends sScrMsg +{ + const ObjID weapon; +} + +// Messages: "SignalAI" +class sAISignalMsg extends sScrMsg +{ + const string signal; +} + +// Messages: "PatrolPoint" +class sAIPatrolPointMsg extends sScrMsg +{ + const ObjID patrolObj; +} + +// Messages: "Alertness" +class sAIAlertnessMsg extends sScrMsg +{ + const eAIScriptAlertLevel level; + const eAIScriptAlertLevel oldLevel; +} + +// Messages: "HighAlert" +class sAIHighAlertMsg extends sScrMsg +{ + const eAIScriptAlertLevel level; + const eAIScriptAlertLevel oldLevel; +} + +// Messages: "AIModeChange" +class sAIModeChangeMsg extends sScrMsg +{ + const eAIMode mode; + const eAIMode previous_mode; +} + +// Messages: "ObjActResult" +class sAIObjActResultMsg extends sScrMsg +{ + const eAIAction action; + const eAIActionResult result; + const cMultiParm actdata; + const ObjID target; +} + +// Messages: "PhysFellAsleep", "PhysWokeUp", "PhysMadePhysical", "PhysMadeNonPhysical", "PhysCollision", +// "PhysContactCreate", "PhysContactDestroy", "PhysEnter", "PhysExit" +class sPhysMsg extends sScrMsg +{ + const int Submod; + const ePhysCollisionType collType; + const ObjID collObj; + const int collSubmod; + const float collMomentum; + const vector collNormal; + const vector collPt; + const ePhysContactType contactType; + const ObjID contactObj; + const int contactSubmod; + const ObjID transObj; + const int transSubmod; +} + +// Messages: stim message names are the stim name with "Stimulus" appended, "Stimulus", +// for example for a stim named "Fire" the message name would be "FireStimulus" +// +// Note: Because message handlers are declared as regular squirrel functions whose names have to be valid identifiers, +// you want to stim names to only contain alpha numeric characters and underscore. Otherwise you have to replace +// illegal characters with underscore in the function name or handle them through the generic OnMessage() handler +// which is less efficient and messier. +class sStimMsg extends sScrMsg +{ + const StimID stimulus; + const float intensity; + const StimSensorID sensor; + const StimSourceID source; +} + +// Messages: "ReportMessage" +class sReportMsg extends sScrMsg +{ + const int WarnLevel; + const int Flags; + const int Types; + const string TextBuffer; + + SetTextBuffer(string s); +} + + +// ---------------------------------------------------------------- +// THIEF SPECIALIZED MESSSAGES +// ---------------------------------------------------------------- + +// Messages: "DarkGameModeChange" +class sDarkGameModeScrMsg extends sScrMsg +{ + const BOOL resuming; + const BOOL suspending; +} + +// Messages: "PickStateChange" +class sPickStateScrMsg extends sScrMsg +{ + const int prevstate; + const int currentstate; +} + + +// ---------------------------------------------------------------- +// SS2 SPECIALIZED MESSSAGES +// ---------------------------------------------------------------- + +// Messages: "YorNDone" +class sYorNMsg extends sScrMsg +{ + const bool yes; +} + +// Messages: "KeypadDone" +class sKeypadMsg extends sScrMsg +{ + const int code; +} diff --git a/DOC/squirrel_script/API-reference_services.txt b/DOC/squirrel_script/API-reference_services.txt new file mode 100644 index 0000000..b53aea2 --- /dev/null +++ b/DOC/squirrel_script/API-reference_services.txt @@ -0,0 +1,743 @@ +Script services provide functions to access systems in the engine. + +A script service is accessed simply by using the service name and calling a member function in it. +For example: + + Object.AddMetaProperty(self, "FrobInert"); + + +In a few cases function availability or arguments differ between Thief 1/G, Thief 2 and SS2. Those cases +are denoted with C-style #ifdef declarations. In other cases some appended functions, or an entire service, +may only be available from a certain API version and up. The API version is the same as returned by the +function "GetAPIVersion" (see "API-reference.txt" for version listing). Those are denoted with a comment +like this + + // **** Available only in API version 3+ **** + +where the minimum API version is specified (3 in this example). All subsequent functions require at least +that API version. + + +// ---------------------------------------------------------------- +// COMMON SERVICES +// ---------------------------------------------------------------- + +// **** Available only in API version 3+ **** +Version +{ + GetAppName(BOOL title_only, string & result); + GetVersion(int_ref major, int_ref minor); + int IsEditor(); + GetGame(string & result); + GetGamsys(string & result); + GetMap(string & result); + HRESULT GetCurrentFM(string & result); + HRESULT GetCurrentFMPath(string & result); + FMizeRelativePath(string relpath, string & result); + FMizePath(string path, string & result); +} + +// **** Available only in API version 3+ **** +Engine +{ + BOOL ConfigIsDefined(string name); + BOOL ConfigGetInt(string name, int_ref value); + BOOL ConfigGetFloat(string name, float_ref value); + BOOL ConfigGetRaw(string name, string & value); + float BindingGetFloat(string name); + BOOL FindFileInPath(string path_config_var, string filename, string & fullname); + BOOL IsRunningDX6(); + GetCanvasSize(int_ref width, int_ref height); + float GetAspectRatio(); + GetFog(int_ref r, int_ref g, int_ref b, float_ref dist); + SetFog(int r, int g, int b, float dist); + GetFogZone(int iZone, int_ref r, int_ref g, int_ref b, float_ref dist); + SetFogZone(int iZone, int r, int g, int b, float dist); + GetWeather(int_ref precip_type, float_ref precip_freq, float_ref precip_speed, float_ref vis_dist, + float_ref rend_radius, float_ref alpha, float_ref brightness, float_ref snow_jitter, + float_ref rain_len, float_ref splash_freq, float_ref splash_radius, float_ref splash_height, + float_ref splash_duration, string & texture, vector & wind); + SetWeather(int precip_type, float precip_freq, float precip_speed, float vis_dist, + float rend_radius, float alpha, float brightness, float snow_jitter, + float rain_len, float splash_freq, float splash_radius, float splash_height, + float splash_duration, string texture, vector wind); + BOOL PortalRaycast(vector from, vector to, vector & hit_location); + int ObjRaycast(vector from, vector to, vector & hit_location, object & hit_object, int ShortCircuit, int flags, object ignore1, object ignore2); + + // **** Available only in API version 4+ **** + SetEnvMapZone(int iZone, string texture); +} + +Object +{ + ObjID BeginCreate(object archetype_or_clone); + HRESULT EndCreate(object obj); + ObjID Create(object archetype_or_clone); + HRESULT Destroy(object obj); + BOOL Exists(object obj); + HRESULT SetName(object obj, string name); + string GetName(object obj); + ObjID Named(string name); + HRESULT AddMetaProperty(object obj, object metaprop); + HRESULT RemoveMetaProperty(object obj, object metaprop); + BOOL HasMetaProperty(object obj, object metaprop); + BOOL InheritsFrom(object obj, object archetype_or_metaprop); + BOOL IsTransient(object obj); + HRESULT SetTransience(object obj, BOOL is_transient); + vector Position(object obj); + vector Facing(object obj); + HRESULT Teleport(object obj, vector position, vector facing, object ref_frame = 0); +#ifndef THIEF1 + BOOL IsPositionValid(object obj); +#endif +#ifdef THIEF2 + ObjID FindClosestObjectNamed(ObjID objId, string name); +#endif + int AddMetaPropertyToMany(object metaprop, string ToSet); + int RemoveMetaPropertyFromMany(object metaprop, string ToSet); + BOOL RenderedThisFrame(object scr_obj); + + // **** Available only in API version 3+ **** +#ifdef SHOCK + ObjID FindClosestObjectNamed(ObjID objId, string name); +#endif + vector ObjectToWorld(object obj, vector obj_pos); + + // **** Available only in API version 7+ **** + vector WorldToObject(object obj, vector world_pos); + BOOL CalcRelTransform(object parent_obj, object child_obj, vector & rel_pos, vector & rel_facing, int rel_type, int sub_or_vhot_or_joint); + + // **** Available only in API version 8+ **** + ObjID Archetype(object scr_obj); +} + +Property +{ + cMultiParm Get(object obj, string prop, string field = null); + HRESULT Set(object obj, string prop, string field, cMultiParm val); + HRESULT SetSimple(object obj, string prop, cMultiParm val); +#ifndef THIEF1 + HRESULT SetLocal(object obj, string prop, string field, cMultiParm val); +#endif + HRESULT Add(object obj, string prop); + HRESULT Remove(object obj, string prop); + HRESULT CopyFrom(object targ, string prop, object src); + BOOL Possessed(object obj, string prop); + + // **** Available only in API version 11+ **** +#ifdef THIEF1 + HRESULT SetLocal(object obj, string prop, string field, cMultiParm val); +#endif + BOOL PossessedSimple(object obj, string prop); +} + +Physics +{ + HRESULT SubscribeMsg(object phys_obj, int message_types); + HRESULT UnsubscribeMsg(object phys_obj, int message_types); + ObjID LaunchProjectile(object launcher, object proj, float power, int flags, vector add_vel); + HRESULT SetVelocity(object obj, vector vel); + HRESULT GetVelocity(object obj, vector & vel); +#ifdef THIEF2 + HRESULT ControlVelocity(object obj, vector vel); + HRESULT StopControlVelocity(object obj); +#endif + HRESULT SetGravity(object obj, float gravity); + float GetGravity(object obj); + + // **** Available only in API version 1+ **** + BOOL HasPhysics(object obj); + BOOL IsSphere(object obj); + BOOL IsOBB(object obj); + HRESULT ControlCurrentLocation(object obj); + HRESULT ControlCurrentRotation(object obj); + HRESULT ControlCurrentPosition(object obj); + HRESULT DeregisterModel(object obj); + PlayerMotionSetOffset(int subModel, vector & offset); + HRESULT Activate(const object obj); + BOOL ValidPos(const object obj); + + // **** Available only in API version 3+ **** + BOOL IsRope(object obj); + GetClimbingObject(object climber, object & climbobj); +} + +Link +{ + LinkID Create(linkkind kind, object from, object to); + HRESULT Destroy(LinkID destroy_me); + BOOL AnyExist(linkkind kind = 0, object from = 0, object to = 0); + linkset GetAll(linkkind kind = 0, object from = 0, object to = 0); + LinkID GetOne(linkkind kind = 0, object from = 0, object to = 0); + HRESULT BroadcastOnAllLinks(object SelfObj, string Message, linkkind recipients); + HRESULT BroadcastOnAllLinksData(object SelfObj, string Message, linkkind recipients, cMultiParm linkdata); + HRESULT CreateMany(linkkind kind, string FromSet, string ToSet); + HRESULT DestroyMany(linkkind kind, string FromSet, string ToSet); + linkset GetAllInherited(linkkind kind = 0, object from = 0, object to = 0); + linkset GetAllInheritedSingle(linkkind kind = 0, object from = 0, object to = 0); +} + +LinkTools +{ + int LinkKindNamed(string name); + string LinkKindName(int id); + HRESULT LinkGet(int id, sLink& l); + cMultiParm LinkGetData(int id, string field); + HRESULT LinkSetData(int id, string field, cMultiParm val); +} + +ActReact +{ + HRESULT React(reaction_kind what, float stim_intensity, object target = 0, object agent = 0, cMultiParm parm1 = 0, cMultiParm parm2 = 0, cMultiParm parm3 = 0, + cMultiParm parm4 = 0, cMultiParm parm5 = 0, cMultiParm parm6 = 0, cMultiParm parm7 = 0, cMultiParm parm8 = 0); +#ifndef THIEF1 + HRESULT Stimulate(object who, stimulus_kind what, float how_much, object source = 0); +#else + HRESULT Stimulate(object who, stimulus_kind what, float how_much); +#endif + int GetReactionNamed(string name); + string GetReactionName(int id); + HRESULT SubscribeToStimulus(object obj, stimulus_kind what); + HRESULT UnsubscribeToStimulus(object obj, stimulus_kind what); + HRESULT BeginContact(object source, object sensor); + HRESULT EndContact(object source, object sensor); + HRESULT SetSingleSensorContact(object source, object sensor); +} + +Data +{ + string GetString(string table, string name, string def = "", string relpath = "strings"); + string GetObjString(ObjID obj, string table); + int DirectRand(); + int RandInt(int low, int high); + float RandFlt0to1(); + float RandFltNeg1to1(); +} + +AI +{ + BOOL MakeGotoObjLoc(ObjID objIdAI, object objIdTarget, eAIScriptSpeed speed = kNormalSpeed, eAIActionPriority = kNormalPriorityAction, cMultiParm dataToSendOnReach = null); + BOOL MakeFrobObjWith(ObjID objIdAI, object objIdTarget, object objWith, eAIActionPriority = kNormalPriorityAction, cMultiParm dataToSendOnReach = null); + BOOL MakeFrobObj(ObjID objIdAI, object objIdTarget, eAIActionPriority = kNormalPriorityAction, cMultiParm dataToSendOnReach = null); + eAIScriptAlertLevel GetAlertLevel(ObjID objIdAI); + SetMinimumAlert(ObjID objIdAI, eAIScriptAlertLevel level); + ClearGoals(ObjID objIdAI); + SetScriptFlags(ObjID objIdAI, int iFlags); + ClearAlertness(ObjID objIdAI); + Signal(ObjID objIdAI, string signal); + BOOL StartConversation(ObjID conversationID); + + // **** Available only in API version 11+ **** + BOOL Stun(object who, string startTags, string loopTags, float sec); + BOOL IsStunned(object who); + BOOL UnStun(object who); + BOOL Freeze(object who, float sec); + BOOL IsFrozen(object who); + BOOL UnFreeze(object who); +} + +Sound +{ + BOOL PlayAtLocation(object CallbackObject, string SoundName, vector & Vector, eSoundSpecial Special = kSoundNormal); + BOOL PlayAtObject(object CallbackObject, string SoundName, object TargetObject, eSoundSpecial Special = kSoundNormal); + BOOL Play(object CallbackObject, string SoundName, eSoundSpecial Special = kSoundNormal); + BOOL PlayAmbient(object CallbackObject, string SoundName, eSoundSpecial Special = kSoundNormal); + BOOL PlaySchemaAtLocation(object CallbackObject, object Schema, vector & Vector); + BOOL PlaySchemaAtObject(object CallbackObject, object Schema, object SourceObject); + BOOL PlaySchema(object CallbackObject, object Schema); + BOOL PlaySchemaAmbient(object CallbackObject, object Schema); + BOOL PlayEnvSchema(object CallbackObject, string Tags, object SourceObject = 0, object AgentObject = 0, eEnvSoundLoc loc = kEnvSoundOnObj); +#ifndef THIEF1 + BOOL PlayAtLocationNet(object CallbackObject, string SoundName, vector & Vector, eSoundSpecial Special = kSoundNormal, eSoundNetwork Network = kSoundNetDefault); + BOOL PlayAtObjectNet(object CallbackObject, string SoundName, object TargetObject, eSoundSpecial Special = kSoundNormal, eSoundNetwork Network = kSoundNetDefault); + BOOL PlayNet(object CallbackObject, string SoundName, eSoundSpecial Special = kSoundNormal, eSoundNetwork Network = kSoundNetDefault); + BOOL PlayAmbientNet(object CallbackObject, string SoundName, eSoundSpecial Special = kSoundNormal, eSoundNetwork Network = kSoundNetDefault); + BOOL PlaySchemaAtLocationNet(object CallbackObject, object Schema, vector & Vector, eSoundNetwork Network = kSoundNetDefault); + BOOL PlaySchemaAtObjectNet(object CallbackObject, object Schema, object SourceObject, eSoundNetwork Network = kSoundNetDefault); + BOOL PlaySchemaNet(object CallbackObject, object Schema, eSoundNetwork Network = kSoundNetDefault); + BOOL PlaySchemaAmbientNet(object CallbackObject, object Schema, eSoundNetwork Network = kSoundNetDefault); + BOOL PlayEnvSchemaNet(object CallbackObject, string Tags, object SourceObject = 0, object AgentObject = 0, eEnvSoundLoc loc = kEnvSoundOnObj, eSoundNetwork Network = kSoundNetDefault); +#endif + BOOL PlayVoiceOver(object cb_obj, object Schema); + int Halt(object TargetObject, string SoundName = "", object CallbackObject = 0); + BOOL HaltSchema(object TargetObject, string SoundName = "", object CallbackObject = 0); + HRESULT HaltSpeech(object speakerObj); + BOOL PreLoad(string SpeechName); +} + +AnimTexture +{ + HRESULT ChangeTexture(object refobj, string fam1, string tx1, string fam2, string tx2); +} + +PGroup +{ + HRESULT SetActive(ObjID PGroupObjID, BOOL active); +} + +Camera +{ + HRESULT StaticAttach(object attachee); + HRESULT DynamicAttach(object attachee); + HRESULT CameraReturn(object attachee); + HRESULT ForceCameraReturn(); + + // **** Available only in API version 6+ **** + ObjID GetCameraParent(); + BOOL IsRemote(); + vector GetPosition(); + vector GetFacing(); + + // **** Available only in API version 7+ **** + vector CameraToWorld(vector local_pos); + vector WorldToCamera(vector world_pos); +} + +Light +{ + Set(object obj, int mode, float min_brightness, float max_brightness); + SetMode(object obj, int mode); + Activate(object obj); + Deactivate(object obj); + Subscribe(object obj); + Unsubscribe(object obj); + int GetMode(object obj); +} + +Door +{ + BOOL CloseDoor(object door_obj); + BOOL OpenDoor(object door_obj); + eDoorStatus GetDoorState(object door_obj); + HRESULT ToggleDoor(object door_obj); + + // **** Available only in API version 2+ **** + HRESULT SetBlocking(object door_obj, BOOL state); + BOOL GetSoundBlocking(object door_obj); +} + +Damage +{ + HRESULT Damage(object victim, object culprit, int how_much, int what_kind = 0); + HRESULT Slay(object victim, object culprit); + HRESULT Resurrect(object victim, object culprit = 0); +} + +Container +{ + HRESULT Add(object obj, object container, int type = 0, int flags = CTF_COMBINE); + HRESULT Remove(object obj, object container = 0); + HRESULT MoveAllContents(object src, object targ, int flags = CTF_COMBINE); + + // **** Available only in API version 1+ **** + HRESULT StackAdd(object src, int quantity); + eContainType IsHeld(object container, object containee); +} + +Quest +{ + BOOL SubscribeMsg(object obj, string name, eQuestDataType type = kQuestDataUnknown); + BOOL UnsubscribeMsg(object obj, string name); + HRESULT Set(string name, int value, eQuestDataType type = kQuestDataMission); + int Get(string name); + BOOL Exists(string name); + BOOL Delete(string name); + + // **** Available only in API version 10+ **** + // returns a squirrels table containing all qvars of the specified type (an empty table is returned if there are no qvars) + // (the table will be generated each time this function is called, so cache it in a 'local' var if you need to access it several times) + sqtable GetAllVars(eQuestDataType type); + + // set/get campaign quest bin data as a blob + bool BinSet(string name, sqblob blob); + // (returns null if no data with that name was found) + sqblob BinGet(string name); + + // set/get campaign quest bin data as a squirrel table (never use BinGetTable on data set with BinSet) + // the table may contain elements of the following types: null, int, float, bool, string, vector, array, blob + // arrays may contain elements of the following types: null, int, float, bool, string, vector + // nested tables or arrays are not supported + bool BinSetTable(string name, sqtable table); + // (returns null if no data with that name was found) + sqtable BinGetTable(string name); + + // same as the qvar counterparts above but for quest bin data + BOOL BinExists(string name); + BOOL BinDelete(string name); +} + +Puppet +{ + BOOL PlayMotion(const object obj, string name); +} + +Locked +{ + BOOL IsLocked(object obj); +} + +Key +{ + BOOL TryToUseKey(object key_obj, object lock_obj, eKeyUse how); +} + +// **** Available only in API version 1+ **** +Networking +{ + HRESULT Broadcast(object obj, string msg, BOOL sendFromProxy = FALSE, cMultiParm data = null); + HRESULT SendToProxy(object toPlayer, object obj, string msg, cMultiParm data = null); + HRESULT TakeOver(object obj); + HRESULT GiveTo(object obj, object toPlayer); + BOOL IsPlayer(object obj); + BOOL IsMultiplayer(); + timer_handle SetProxyOneShotTimer(object toObj, string msg, float time, cMultiParm data = null); + ObjID FirstPlayer(); + ObjID NextPlayer(); + HRESULT Suspend(); + HRESULT Resume(); + BOOL HostedHere(object obj); + BOOL IsProxy(object obj); + BOOL LocalOnly(object obj); + BOOL IsNetworking(); + ObjID Owner(object obj); + + // **** Available only in API version 11+ **** + HRESULT CreateContentProxy(const object player, const object content); + BOOL AmHost(); + int NumPlayers(); + int MyPlayerNum(); + int ObjToPlayerNum(object player); + ObjID PlayerNumToObj(int player); + // returns null if player object isn't a valid player + string GetPlayerName(object player); +} + +CD +{ + HRESULT SetBGM(int track); + HRESULT SetTrack(int track, uint flags); +} + +Debug +{ + HRESULT MPrint(string s); + HRESULT Command(string cmd, string arg = null); + HRESULT Break(); + + // **** Available only in API version 3+ **** + HRESULT Log(string s); +} + + +// ---------------------------------------------------------------- +// THIEF SERVICES +// ---------------------------------------------------------------- + +DarkGame +{ + HRESULT KillPlayer(); + HRESULT EndMission(); + HRESULT FadeToBlack(float time); + + // **** Available only in API version 2+ **** + HRESULT FoundObject(ObjID obj); + BOOL ConfigIsDefined(string name); + BOOL ConfigGetInt(string name, int_ref value); + BOOL ConfigGetFloat(string name, float_ref value); + float BindingGetFloat(string name); + BOOL GetAutomapLocationVisited(int page, int location); + HRESULT SetAutomapLocationVisited(int page, int location); + + // **** Available only in API version 3+ **** + SetNextMission(int mission); + int GetCurrentMission(); + + // **** Available only in API version 8+ **** + BOOL RespawnPlayer(); + HRESULT FadeIn(float time); +} + +DarkUI +{ + HRESULT TextMessage(string message, int color = 0, int timeout = DEFAULT_TIMEOUT); + HRESULT ReadBook(string text, string art); + ObjID InvItem(); + ObjID InvWeapon(); + HRESULT InvSelect(object obj); + BOOL IsCommandBound(string cmd); + string DescribeKeyBinding(string cmd); +} + +PickLock +{ + BOOL Ready(object picker, object pick_obj); + BOOL UnReady(object picker, object pick_obj); + BOOL StartPicking(object picker, object pick_obj, object locked_obj); + BOOL FinishPicking(object pick_obj); + BOOL CheckPick(object pick_obj, object locked_obj, int stage); + BOOL DirectMotion(BOOL start); +} + +DrkInv +{ + CapabilityControl(eDrkInvCap cap_change, eDrkInvControl control); + AddSpeedControl(string name, float speed_fac, float rot_fac); + RemoveSpeedControl(string name); +} + +DrkPowerups +{ + TriggerWorldFlash(object obj); + BOOL ObjTryDeploy(object src_object, object deploy_arch); + CleanseBlood(object water_src_object, float rad); +} + +PlayerLimbs +{ + HRESULT Equip(object item); + HRESULT UnEquip(object item); + HRESULT StartUse(object item); + HRESULT FinishUse(object item); +} + +Weapon +{ + HRESULT Equip(object weapon, int type = 0); + HRESULT UnEquip(object weapon); + BOOL IsEquipped(object owner, object weapon); + HRESULT StartAttack(object owner, object weapon); + HRESULT FinishAttack(object owner, object weapon); +} + +Bow +{ + HRESULT Equip(); + HRESULT UnEquip(); + BOOL IsEquipped(); + HRESULT StartAttack(); + HRESULT FinishAttack(); + HRESULT AbortAttack(); + BOOL SetArrow(object arrow); +} + +// **** Available only in API version 3+ **** +DarkOverlay +{ + AddHandler(IDarkOverlayHandler handler); + RemoveHandler(IDarkOverlayHandler handler); + int GetBitmap(string name, string path = "intrface\\"); + FlushBitmap(int handle); + GetBitmapSize(int handle, int_ref width, int_ref height); + BOOL WorldToScreen(vector pos, int_ref x, int_ref y); + BOOL GetObjectScreenBounds(object obj, int_ref x1, int_ref y1, int_ref x2, int_ref y2); + int CreateTOverlayItem(int x, int y, int width, int height, int alpha, BOOL trans_bg); + int CreateTOverlayItemFromBitmap(int x, int y, int alpha, int bm_handle, BOOL trans_bg); + DestroyTOverlayItem(int handle); + UpdateTOverlayAlpha(int handle, int alpha); + UpdateTOverlayPosition(int handle, int x, int y); + UpdateTOverlaySize(int handle, int width, int height); + DrawBitmap(int handle, int x, int y); + DrawSubBitmap(int handle, int x, int y, int src_x, int src_y, int src_width, int src_height); + SetTextColorFromStyle(int style_color); + SetTextColor(int r, int g, int b); + GetStringSize(string text, int_ref width, int_ref height); + DrawString(string text, int x, int y); + DrawLine(int x1, int y1, int x2, int y2); + FillTOverlay(int color_idx = 0, int alpha = 255); + BOOL BeginTOverlayUpdate(int handle); + EndTOverlayUpdate(); + DrawTOverlayItem(int handle); +} + + +// ---------------------------------------------------------------- +// SS2 SERVICES +// ---------------------------------------------------------------- + +ShockGame +{ + HRESULT DestroyCursorObj(); + HRESULT DestroyInvObj(object DestroyObj); + HRESULT HideInvObj(object DestroyObj); + HRESULT SetPlayerPsiPoints(int points); + int GetPlayerPsiPoints(); + HRESULT AttachCamera(string s); + HRESULT CutSceneModeOn(string sceneName); + HRESULT CutSceneModeOff(); + int CreatePlayerPuppet(string modelName); + int CreatePlayerPuppetDefault(); + HRESULT DestroyPlayerPuppet(); + HRESULT Replicator(object RepObj); + HRESULT Container(object ContainObj); + HRESULT YorN(object BaseObj, string s); + HRESULT Keypad(object BaseObj); + HRESULT HRM(int hacktype, object Obj, BOOL frompsi); + HRESULT TechTool(object Obj); + HRESULT UseLog(object LogObj, BOOL PickedUpByMe); + BOOL TriggerLog(int usetype, int uselevel, int which, BOOL show_mfd); + HRESULT FindLogData(object LogObj, int usetype, int_ref level, int_ref which); + HRESULT PayNanites(int quan); + HRESULT OverlayChange(int which, int mode); + ObjID Equipped(int slot); + HRESULT LevelTransport(string newlevel, int marker, uint flags); + BOOL CheckLocked(object CheckObj, BOOL verbose, object player); + HRESULT AddText(string msg, object player, int time = DEFAULT_MSG_TIME); + HRESULT AddTranslatableText(string msg, string table, object player, int time = DEFAULT_MSG_TIME); + HRESULT AmmoLoad(object GunObj, object AmmoObj); + int GetClip(object GunObj); + HRESULT AddExp(object Who, int amount, BOOL verbose); + BOOL HasTrait(object Who, eTrait trait); + BOOL HasImplant(object Who, eImplant implant); + HRESULT HealObj(object Who, int amt); + HRESULT OverlaySetObj(int which, object Obj); + HRESULT Research(); + string GetArchetypeName(object Obj); + BOOL OverlayOn(int which); + ObjID FindSpawnPoint(object Obj, uint flags); + int CountEcoMatching(int val); + int GetStat(object who, eStats which); + ObjID GetSelectedObj(); + BOOL AddInvObj(object obj); + HRESULT RecalcStats(object who); + HRESULT PlayVideo(string vidname); + HRESULT ClearRadiation(); + SetPlayerVolume(float volume); + int RandRange(int low, int high); + BOOL LoadCursor(object obj); + AddSpeedControl(string name, float speed_fac, float rot_fac); + RemoveSpeedControl(string name); + HRESULT PreventSwap(); + ObjID GetDistantSelectedObj(); + HRESULT Equip(int slot, object Obj); + HRESULT OverlayChangeObj(int which, int mode, object Obj); + HRESULT SetObjState(object Obj, eObjState state); + HRESULT RadiationHack(); + HRESULT DestroyAllByName(string name); + HRESULT AddTextObjProp(object Obj, string propname, object player, int time = DEFAULT_MSG_TIME); + HRESULT DisableAlarmGlobal(); + Frob(BOOL in_inv = FALSE); + HRESULT TweqAllByName(string name, BOOL state); + HRESULT SetExplored(int maploc, char val = 1); + HRESULT RemoveFromContainer(object Obj, object Container); + HRESULT ActivateMap(); + int SimTime(); + StartFadeIn(int time, uchar red, uchar green, uchar blue); + StartFadeOut(int time, uchar red, uchar green, uchar blue); + HRESULT GrantPsiPower(object who, ePsiPowers which); + BOOL ResearchConsume(object Obj); + HRESULT PlayerMode(ePlayerMode mode); + HRESULT EndGame(); + BOOL AllowDeath(); + HRESULT AddAlarm(int time); + HRESULT RemoveAlarm(); + float GetHazardResistance(int endur); + int GetBurnDmg(); + ObjID PlayerGun(); + BOOL IsPsiActive(ePsiPowers power); + HRESULT PsiRadarScan(); + ObjID PseudoProjectile(object source, object emittype); + HRESULT WearArmor(object Obj); + HRESULT SetModify(object Obj, int modlevel); + BOOL Censored(); + HRESULT DebriefMode(int mission); + HRESULT TlucTextAdd(string name, string table, int offset); + HRESULT Mouse(BOOL mode, BOOL clear); + HRESULT RefreshInv(); + HRESULT TreasureTable(object Obj); + ObjID OverlayGetObj(); + HRESULT VaporizeInv(); + HRESULT ShutoffPsi(); + HRESULT SetQBHacked(string qbname, int qbval); + int GetPlayerMaxPsiPoints(); + HRESULT SetLogTime(int level, int logtype, int which); + HRESULT AddTranslatableTextInt(string msg, string table, object player, int val, int time = DEFAULT_MSG_TIME); + HRESULT ZeroControls(object Obj, BOOL poll); + HRESULT SetSelectedPsiPower(int which); + BOOL ValidGun(object Obj); + HRESULT AddTranslatableTextIndexInt(string msg, string table, object player, int index, int val, int time = DEFAULT_MSG_TIME); + BOOL IsAlarmActive(); + HRESULT SlayAllByName(string name); + HRESULT NoMove(BOOL jump_allowed); + HRESULT PlayerModeSimple(int mode); + HRESULT UpdateMovingTerrainVelocity(const object objID, const object next_node, float speed); + BOOL MouseCursor(); + BOOL ConfigIsDefined(string name); + BOOL ConfigGetInt(string name, int_ref value); +} + +ShockObj +{ + ObjID FindScriptDonor(ObjID objID, string name); +} + +ShockWeapon +{ + SetWeaponModel(object obj); + ObjID GetWeaponModel(); + ObjID TargetScan(object projectile); + Home(object projectile, object target); + DestroyMelee(object obj); +} + +ShockPsi +{ + HRESULT OnDeactivate(ePsiPowers power); + uint GetActiveTime(ePsiPowers power); + BOOL IsOverloaded(ePsiPowers power); +} + +ShockAI +{ + BOOL Stun(object who, string startTags, string loopTags, float sec); + BOOL IsStunned(object who); + BOOL UnStun(object who); + BOOL Freeze(object who, float sec); + BOOL IsFrozen(object who); + BOOL UnFreeze(object who); + NotifyEnterTripwire(object who, object what); + NotifyExitTripwire(object who, object what); + BOOL ObjectLocked(object obj); + ValidateSpawn(object creature, object spawnMarker); +} + +// **** Available only in API version 3+ **** +ShockOverlay +{ + AddHandler(IShockOverlayHandler handler); + RemoveHandler(IShockOverlayHandler handler); + SetKeyboardInputCapture(BOOL bCapture); + int GetBitmap(string name, string path = "iface\\"); + FlushBitmap(int handle); + GetBitmapSize(int handle, int_ref width, int_ref height); + BOOL SetCustomFont(int index, string name, string path = "fonts\\"); + GetOverlayRect(int which, int_ref left, int_ref top, int_ref right, int_ref bottom); + int GetCursorMode(); + ClearCursorMode(); + BOOL SetCursorBitmap(string name, string path = "iface\\"); + SetInterfaceMouseOverObject(object obj); + GetInterfaceFocusObject(object & obj); + OpenLookPopup(object obj); + ToggleLookCursor(); + BOOL StartObjectDragDrop(object obj); + PlaySound(string schema_name); + BOOL WorldToScreen(vector pos, int_ref x, int_ref y); + BOOL GetObjectScreenBounds(object obj, int_ref x1, int_ref y1, int_ref x2, int_ref y2); + int CreateTOverlayItem(int x, int y, int width, int height, int alpha, BOOL trans_bg); + int CreateTOverlayItemFromBitmap(int x, int y, int alpha, int bm_handle, BOOL trans_bg); + DestroyTOverlayItem(int handle); + UpdateTOverlayAlpha(int handle, int alpha); + UpdateTOverlayPosition(int handle, int x, int y); + UpdateTOverlaySize(int handle, int width, int height); + DrawBitmap(int handle, int x, int y); + DrawSubBitmap(int handle, int x, int y, int src_x, int src_y, int src_width, int src_height); + DrawObjectIcon(object obj, int x, int y); + SetFont(int font_type); + SetTextColor(int r, int g, int b); + GetStringSize(string text, int_ref width, int_ref height); + DrawString(string text, int x, int y); + DrawLine(int x1, int y1, int x2, int y2); + FillTOverlay(int color_idx = 0, int alpha = 255); + BOOL BeginTOverlayUpdate(int handle); + EndTOverlayUpdate(); + DrawTOverlayItem(int handle); +} diff --git a/DOC/squirrel_script/Custom-API-reference.nut b/DOC/squirrel_script/Custom-API-reference.nut index 224dd8e..b2a8e8c 100644 --- a/DOC/squirrel_script/Custom-API-reference.nut +++ b/DOC/squirrel_script/Custom-API-reference.nut @@ -725,7 +725,7 @@ KEY_DEL = 10323 KEY_HOME = 10311 KEY_END = 10319 KEY_PGUP = 10313 -KEY_PGDN = 10321 = 8192 + KEY_PAD_PGUP +KEY_PGDN = 10321 // 8192 + KEY_PAD_PGDN KEY_LEFT = 10315 KEY_RIGHT = 10317 KEY_UP = 10312 @@ -771,7 +771,7 @@ enum StyleColorKind DarkOverlay.SetTextColorFromStyle //needs confirmation. { StyleColorFG = 0 // foreground StyleColorBG = 1 //background, - StyleColorText, = 2 // text color + StyleColorText = 2 // text color StyleColorHilite = 3 // hilight color StyleColorBright = 4 // bright color StyleColorDim = 5 // dim color diff --git a/DOC/squirrel_script/Custom-API-reference_messages.nut b/DOC/squirrel_script/Custom-API-reference_messages.nut index 939c8f4..fe68268 100644 --- a/DOC/squirrel_script/Custom-API-reference_messages.nut +++ b/DOC/squirrel_script/Custom-API-reference_messages.nut @@ -221,7 +221,7 @@ class sAIModeChangeMsg extends sScrMsg } // Messages: "ObjActResult" -class sAIObjActResultMsg extends sScrMsg AI.MakeGotoObjLoc... +class sAIObjActResultMsg extends sScrMsg { const eAIAction action; const eAIActionResult result; diff --git a/DOC/squirrel_script/Custom-API-reference_services.nut b/DOC/squirrel_script/Custom-API-reference_services.nut index 0c182e5..71ea014 100644 --- a/DOC/squirrel_script/Custom-API-reference_services.nut +++ b/DOC/squirrel_script/Custom-API-reference_services.nut @@ -3,7 +3,7 @@ Script services provide functions to access systems in the engine. A script service is accessed simply by using the service name and calling a member function in it. For example: - Object.AddMetaProperty(self, FrobInert); + Object.AddMetaProperty(self, "FrobInert"); In a few cases function availability or arguments differ between Thief 1/G, Thief 2 and SS2. Those cases @@ -290,7 +290,7 @@ ActReact { HRESULT React(reaction_kind what, float stim_intensity, object target = 0, object agent = 0, cMultiParm parm1 = 0, cMultiParm parm2 = 0, cMultiParm parm3 = 0, cMultiParm parm4 = 0, cMultiParm parm5 = 0, cMultiParm parm6 = 0, cMultiParm parm7 = 0, cMultiParm parm8 = 0); -#ifNOT THIEF1 +#ifndef THIEF1 HRESULT Stimulate(object who, stimulus_kind what, float how_much, object source = 0); #else HRESULT Stimulate(object who, stimulus_kind what, float how_much); @@ -310,7 +310,7 @@ Data // The string table comes from the .str file in finals\strings // The third argument is the default string value to use if it isn't found // The fourth arg is a path relative to art\finals. - string GetString( string table, string name, string def = , string relpath = strings); + string GetString( string table, string name, string def = "", string relpath = "strings"); // Fetch an object string, using the property that corresponds to the table # This uses the ObjID as a key to find a value in a preloaded table, standard tables are obj objdescs and objnames from RES\Strings @@ -403,8 +403,8 @@ Sound BOOL PlayEnvSchemaNet(object CallbackObject, string Tags, object SourceObject = 0, object AgentObject = 0, eEnvSoundLoc loc = kEnvSoundOnObj, eSoundNetwork Network = kSoundNetDefault); #endif BOOL PlayVoiceOver(object cb_obj, object Schema); - int Halt(object TargetObject, string SoundName = , object CallbackObject = 0); - BOOL HaltSchema(object TargetObject, string SoundName = , object CallbackObject = 0); + int Halt(object TargetObject, string SoundName = "", object CallbackObject = 0); + BOOL HaltSchema(object TargetObject, string SoundName = "", object CallbackObject = 0); HRESULT HaltSpeech(object speakerObj); BOOL PreLoad(string SpeechName); } @@ -753,7 +753,7 @@ DarkOverlay // get/load a bitmap that can be used for HUD drawing (max 128 bitmaps can be loaded, cleared when db resets) // returns a handle that can be used in subsequent bitmap functions or -1 if failed to load - int GetBitmap(string name, string path = intrface\\); + int GetBitmap(string name, string path = "intrface\\"); // discard a no longer used bitmap handle, only needs to be called when using a lot of bitmaps to stay below 128 FlushBitmap(int handle); @@ -1007,14 +1007,14 @@ ShockOverlay AddHandler(IShockOverlayHandler handler); RemoveHandler(IShockOverlayHandler handler); SetKeyboardInputCapture(BOOL bCapture); - int GetBitmap(string name, string path = iface\\); + int GetBitmap(string name, string path = "iface\\"); FlushBitmap(int handle); GetBitmapSize(int handle, int_ref width, int_ref height); - BOOL SetCustomFont(int index, string name, string path = fonts\\); + BOOL SetCustomFont(int index, string name, string path = "fonts\\"); GetOverlayRect(int which, int_ref left, int_ref top, int_ref right, int_ref bottom); int GetCursorMode(); ClearCursorMode(); - BOOL SetCursorBitmap(string name, string path = iface\\); + BOOL SetCursorBitmap(string name, string path = "iface\\"); SetInterfaceMouseOverObject(object obj); GetInterfaceFocusObject(object & obj); OpenLookPopup(object obj); diff --git a/DOC/squirrel_script/ReadMe.txt b/DOC/squirrel_script/ReadMe.txt new file mode 100644 index 0000000..6a16bbb --- /dev/null +++ b/DOC/squirrel_script/ReadMe.txt @@ -0,0 +1,335 @@ +SQUIRREL.OSM ReadMe +=================== + +SQUIRREL.OSM provides the ability to write Dark Engine scripts using the Squirrel script language +(www.squirrel-lang.org, en.wikipedia.org/wiki/Squirrel_%28programming_language%29). Squirrel uses a C-like +syntax. This documentation will not cover the language itself, the Squirrel web site and google along with +the samples will have to suffice. + +Script files are text files with Squirrel code, using a ".nut" file extension. A script file can contain +any number of script classes. The game will load all ".nut" files it find in "sq_scripts\" directories of +all mod paths and the FM or base path. The load order will be with the lowest priority path first +and highest last, so that the highest priority path has the final say. In the same fashion as DML files. +Like with DML files the FM or base path, depending on if an FM is active or not, is however loaded first. + +As a script editor there are various general purpose editors that can be used, like Notepad++, adding a +plugin or keywords definitions to get proper squirrel syntax hilighting. Using JavaScript syntax hilighting +would probably also be passable. A user defined syntax highlight definition and auto-complete definition +for Notepad++ is included along with this documentation. + +Script messages and errors will be output to mono. Squirrel "print()" output will also be seen in mono. +When running the game exe, where mono isn't available, all output is redirected to the log file instead. + +Scripts are reloaded every time the OSM is reloaded. That happens each time you return to the editor from +game mode or load a mission. This means you can edit script and the changes will get applied on next OSM +reload, without having to restart the editor. From T2 v1.25 / SS2 2.46 there's also a "script_reload" +command that will force an instant reload. The command is primarily for use in edit mode, but could have +some limited use in game mode. You have to be aware that no "EndScript", "BeginScript" or "Sim" messages +will be sent to scripts before and after reload, which could put all scripts in some weird state, but if +what you're debugging isn't dependent on that it could still be useful. + +While the performance of Squirrel probably isn't the best, as long as you don't do any heavy duty processing +looping over hundreds or thousands of things, or have thousands of script instances actively doing per-frame +updates then this shouldn't be an issue. The vast majority of script functionality is based on handling events +that only fire once in a blue moon or never at all, and once they do they usually only do a few things, like +set some property, send a message and things like that. That should never affect performance. + + +OSM Distribution +---------------- + +SQUIRREL.OSM is included and maintained as part of the dark package, which means that all users that keep +their game up-to-date will automatically have the latest version of the OSM available. It's therefore not +recommended to include the OSM file in FM packages, in the same fashion as original OSMs normally aren't +included either. To use the OSM in a mission you still have to load it like any other OSM in DromEd. + + +Creating a Script +----------------- + +To create a script you derive a class from "SqRootScript", like so + + class MyScript extends SqRootScript + { + } + +That is already a valid script and you could add "MyScript" to an object in the editor. The script won't do +anything of course, as it doesn't have any message handling yet. + +It's fine to have several levels of inheritance, if you want to have script classes with shared functionality +from which you derive other script classes. + + class MyBaseScript extends SqRootScript + { + } + + class MyScript extends MyBaseScript + { + } + +Aside from message handlers (see section below), you are free declare any member functions you want. Just avoid +having their names start with "On", as that should be reserved for message handlers. Otherwise if the function +was incidentally named as en existing message it could get called as a message handler. + + +Message Handling +---------------- + +As briefly mentioned in the introduction, the core of scripts is handling messages. A message handler is +a regular squirrel function with no arguments or return value, named after the message it handles and with +an "On" prefix. If you wanted to handle the "BeginScript" message you'd declare the function: + + class MyScript extends SqRootScript + { + function OnBeginScript() + { + } + } + +The current message can be accessed by calling "message()" from any message handler. That will return a +reference to a sScrMsg or a derived message class, depending on the message. See "API-reference_messages.txt". +The reference lists which messages have special message classes with additional data in them. If we take +the "Timer" message for example, the message type for the OnTimer handler would be sScrTimerMsg, which +has the additional data member "name" with the name of the timer. + + class MyScript extends SqRootScript + { + function On...() + { + ... + SetOneShotTimer("TestTimer", 5); + ... + } + + function OnTimer() + { + if (message().name == "TestTimer") + { + ... + } + } + } + +Because the message name is used as part of the squirrel function name, message names should adhere to the +rules of Squirrel naming. That is only contain alpha numeric characters or underscore (see Squirrel docs). +It is however still possible to declare handlers for messages with other characters in their name by replacing +those characters with underscore in the function name. + + class MyScript extends SqRootScript + { + // handle "J'Accuse" (but of course this would also catch a potential "J-Accuse", "J@Accuse" etc.) + function OnJ_Accuse() + { + ... + } + } + +It's also possible to handle such a message using the generic message handler, "OnMessage". When available, +this will be called if a script doesn't have an explicit message handler for a message. This is slightly +less efficient and clean, so whenever possible use an explicit message handler. + + class MyScript extends SqRootScript + { + function OnMessage() + { + if ( MessageIs("Funky-Msg-Name") ) + { + ... + } + } + } + +Stim message names are based on the stim archetype name with a "Stimulus" suffix. If there's a stim archetype +named "FireStim" then the message handle for it would be: + + class MyScript extends SqRootScript + { + function OnFireStimStimulus() + { + ... + } + } + +If the stim name has non alpha-numeric characters then those have to be replaced with underscore in the handler +name, or the message has to be handled through OnMessage just as any other messages, as described above. + +Message names in Dark are case insensitive, the correct handler function will be found even if there is a case +mismatch. It's however good practice to stick with one version of the names throughout the code. It's not +allowed to declare multiple handler functions for the same message, where the functions just differ by case. +Only one of the handlers will end up being used and the rest ignored. + + +What a script actually does in a message handler can be a variety of things. Look at the samples to +get an idea and browse through the API reference. Looking at other open source OSM code can also be +helpful even if that's written in C++. The principles are the same even if there are some slight +differences in naming any layout. + + +For very special cases, usually debugging, it is possible to declare a global catch-all message handler. +This is a function declared outside of any class: + + function PreFilterMessage(message) + { + // return 'true' if message should be intercepted and not sent to the target script instance + return false; + } + +It will receive all messages sent to squirrel script instances. It even has the ability to prevent the +message from reaching the intended script instance by returning 'true'. Normally you don't want to use +or declare this function, but it could come in handy for tricky debugging cases. + + +Script Variables +---------------- + +While it is possible to declare regular member variables in the script classes, it's important to note that +script instances can be destroyed and recreated at any time. For persistent data you should use the Data +functions in SqRootScript (see "API-reference.txt"), SetData, GetData etc.. This is data that survives +a script reconstruct and is properly handled with savegames. + + +Debugging +--------- + +Debugging can be a bit tricky, keeping an eye on mono is a good start. The best way to do that is to run +in windowed mode so you can see both the game and the mono window. Also don't be afraid to make use of the +"print()" function in squirrel during development. That way you can easily keep track of when some handler +is called, what values things have etc.. When the script is done and working you can remove or comment out +the print() calls again. + +One thing to remember about Squirrel is that while blatant syntax errors will be caught when a script is +loaded and you'll see error messages, other errors like misspelling of variable or function names, or using +wrong types or argument counts for functions, are run-time errors and won't cause an actual error until that +code executes. If you have conditional statements in your code it would be a good idea to make sure all code +paths have been tested to ensure that the code is working. + + +Custom Overlays +--------------- + +For details on the overlay script services see the documentation "doc\script\DarkOverlay.txt" for Thief +or "doc\script\ShockOverlay.txt" for SS2. The squirrel script implementation of the overlay handler +works pretty much the same as the C/C++ interfaces described in that documentation. To make a handler +you just derive a script class from IDarkOverlayHandler for Thief or IShockOverlayHandler for SS2. +The supported handler functions are shown below, they have the same arguments and return values as +the C/C++ counterpart. + +There is one big difference however, and that is that script code can register multiple overlay handlers. +The overlay service has AddHandler/RemoveHandler functions, instead of SetHandler as the C counterpart. +The reason is to avoid conflicts if there a multiple mods active that contain squirrel scripts. It's +possible that two different mods might want to add overlay elements. Accidentally calling Add/RemoveHandler +multiple times won't cause any problems, the only thing to note is that the last added handler is always +moved up to highest priority. + +Just like with regular SqRootScript derived scripts, you only have to declare the functions that you need +to implement custom functionality in. If you for example don't intend to have any code in DrawTOverlay() +then you're better off not declaring that function at all. Even a completely empty handler class is +a valid handler, that does nothing but it is valid. + + // Thief + class MyOverlay extends IDarkOverlayHandler + { + function DrawHUD() + { + } + + function DrawTOverlay() + { + } + + function OnUIEnterMode() + { + } + } + + // SS2 + class MyOverlay extends IShockOverlayHandler + { + function DrawHUD() + { + } + + function DrawTOverlay() + { + } + + function OnUIEnterMode() + { + } + + function CanEnableElement(which) + { + return true; + } + + function IsMouseOver(x, y) + { + return false; + } + + function MouseClick(x, y) + { + return false; + } + + function MouseDblClick(x, y) + { + return false; + } + + function MouseDragDrop(x, y, start_drag, cursor_mode) + { + return false; + } + } + + +Installing an overlay handler also works largely the same as the C/C++ samples, except that AddHandler +and RemoveHandler is used. + + // create a global instance of the overlay handler + myOverlay <- MyOverlay(); + + // + // Script that installs and uninstalls the overlay handler + // Add this script to one (dummy) object in the mission + // + class MyHudScript extends SqRootScript + { + function destructor() + { + // to be on the safe side make really sure the handler is removed when this script is destroyed + // (calling RemoveHandler if it's already been removed is no problem) + DarkOverlay.RemoveHandler(myOverlay); + } + + function OnBeginScript() + { + DarkOverlay.AddHandler(myOverlay); + } + + function OnEndScript() + { + DarkOverlay.RemoveHandler(myOverlay); + } + } + +The above example is for Thief, replace "DarkOverlay" with "ShockOverlay" for SS2. + + +Script Destructor +----------------- + +Squirrel has built-in reference counting and normally things will get cleaned up automatically. For other +game related cleanup there's the "EndScript" message. But should there for some reason or another be a +need for it, it's possible to declare an optional destructor which acts like a C++ destructor, it gets +called when the script instance is about to be deleted. Don't call any script services or engine functions +in this. + + class MyScript extends SqRootScript + { + function destructor() + { + ... + } + } diff --git a/DOC/squirrel_script/samples/SS2_samples.nut b/DOC/squirrel_script/samples/SS2_samples.nut new file mode 100644 index 0000000..26b5472 --- /dev/null +++ b/DOC/squirrel_script/samples/SS2_samples.nut @@ -0,0 +1,861 @@ +// squirrel.osm versions of some allobjs.osm scripts + + +class Battery extends SqRootScript +{ +// MESSAGES: + + // handle "FrobToolEnd" messages (message class is "sFrobMsg", see "API-reference_messages.txt") + function OnFrobToolEnd() + { + // post a "Recharge" message to the frob dest object, with a large enough value to ensure a full recharge + PostMessage(message().DstObjId, "Recharge", 9999); + + // prevent the inventory slot object from being swapped with the battery + // (only want to recharge the dest object and not do an inventory shuffle) + ShockGame.PreventSwap(); + // refresh inventory so it displays the recharged state of the object + ShockGame.RefreshInv(); + } + + // handle "Consume" messages (message class is "sScrMsg") + function OnConsume() + { + // remove 1 stacked battery object + Container.StackAdd(self, -1); + + // check if "StackCount" property value is 0 or if this object doesn't have the property (in which case 'null' is returned) + if ( !GetProperty("StackCount") ) + // there are no stacked batteries left or it isn't stackable, destroy this object + ShockGame.DestroyInvObj(self); + } + + // handle "FrobInvEnd" messages (message class is "sFrobMsg") + function OnFrobInvEnd() + { + // display on-screen help message for battery + ShockGame.AddTranslatableText("HelpBattery", "misc", "Player"); + } +} + +class BaseImplant extends SqRootScript +{ +// METHODS: + + function DoDrain() + { + SetData("timer", SetOneShotTimer("drain", GetProperty("DrainRate"))); + } + + function StartUse() + { + DoDrain(); + SetData("usage", 1); + } + + function StopUse() + { + KillTimer( GetData("timer") ); + SetData("usage", 0); + } + +// MESSAGES: + + function OnBeginScript() + { + if (ShockGame.Equipped(ePlayerEquip.kEquipArmor) == self + || ShockGame.Equipped(ePlayerEquip.kEquipSpecial) == self + || ShockGame.Equipped(ePlayerEquip.kEquipSpecial2) == self) + { + if (GetProperty("Energy") > 0) + { + DoDrain(); + SetData("usage", 1); + } + } + } + + function OnEndScript() + { + if ( GetData("usage") ) + KillTimer( GetData("timer") ); + } + + function OnTurnOn() + { + if (GetProperty("Energy") > 0 && !GetData("usage")) + StartUse(); + } + + function OnTurnOff() + { + if ( GetData("usage") ) + StopUse(); + } + + function OnRecharge() + { + local iEnergy = GetProperty("Energy"); + if ((!iEnergy && ShockGame.Equipped(ePlayerEquip.kEquipSpecial) == self) + || ShockGame.Equipped(ePlayerEquip.kEquipArmor) == self + || ShockGame.Equipped(ePlayerEquip.kEquipSpecial2) == self) + { + if ( !GetData("usage") ) + StartUse(); + } + + local iFiniteRechargeAmount = message().data; + + local iNewEnergy = iFiniteRechargeAmount ? iEnergy + iFiniteRechargeAmount : 9999; + + local iMaxCharge = Property.Get("Player", "BaseTechDesc", "Maintain") * 6 + 100; + if (iNewEnergy > iMaxCharge) + iNewEnergy = iMaxCharge; + + SetProperty("Energy", iNewEnergy); + + if (iFiniteRechargeAmount && iNewEnergy > iEnergy) + PostMessage(message().from, "Consume"); + } + + function OnTimer() + { + if (message().name == "drain") + { + local iNewEnergy = GetProperty("Energy") - GetProperty("DrainAmt"); + if (iNewEnergy < 0) + iNewEnergy = 0; + + SetProperty("Energy", iNewEnergy); + + ShockGame.RefreshInv(); + + if (iNewEnergy <= 0) + { + if ( GetData("usage") ) + { + Sound.PlaySchemaAmbient(self, "bb07"); + StopUse(); + } + } + else + DoDrain(); + } + } + + function OnFrobToolEnd() + { + if ( Object.InheritsFrom(message().DstObjId, "Recharging Station") ) + { + PostMessage(self, "Recharge"); + + Sound.PlayEnvSchema(message().DstObjId, "Event Activate", message().DstObjId, 0, eEnvSoundLoc.kEnvSoundAtObjLoc); + + ShockGame.PreventSwap(); + } + } +} + +class TestImplant extends BaseImplant +{ +// METHODS: + + function StartUse() + { + print("Equipped!"); + + base.StartUse(); + } + + function StopUse() + { + print("Un-Equipped!"); + + base.StopUse(); + } +} + +class WormHeartImplant extends BaseImplant +{ +// METHODS: + + function StartUse() + { + SetData("Timer", SetOneShotTimer("HealTick", 30)); + + base.StartUse(); + } + + function StopUse() + { + local hTimer = GetData("Timer"); + if (hTimer != -1) + { + KillTimer(hTimer); + SetData("Timer", -1); + } + + ActReact.Stimulate("Player", "Venom", 4.0); + + base.StopUse(); + } + +// MESSAGES: + + function OnTimer() + { + if (message().name == "HealTick") + { + SetData("Timer", SetOneShotTimer("HealTick", 30)); + + ShockGame.HealObj("Player", 1); + + Sound.PlayEnvSchema(self, "Event Activate", 0, 0, eEnvSoundLoc.kEnvSoundAmbient); + } + else + base.OnTimer(); + } +} + +class Recycler extends SqRootScript +{ +// MESSAGES: + + function OnFrobToolEnd() + { + local item = message().DstObjId; + if (item) + { + if (ShockGame.Equipped(ePlayerEquip.kEquipWeapon) == item + || ShockGame.Equipped(ePlayerEquip.kEquipWeaponAlt) == item + || ShockGame.Equipped(ePlayerEquip.kEquipArmor) == item + || ShockGame.Equipped(ePlayerEquip.kEquipSpecial) == item + || ShockGame.Equipped(ePlayerEquip.kEquipSpecial2) == item) + { + return; + } + + local iAmount = Property.Get(item, "Recycle", ""); + if (iAmount > 0) + { + local iCount = Property.Get(item, "StackCount", ""); + if (!iCount) + iCount = 1; + + ShockGame.PayNanites(-iAmount * iCount); + + Object.Destroy(item); + + Sound.PlayEnvSchema(self, "Event Activate", 0, 0, eEnvSoundLoc.kEnvSoundAmbient); + + if ( Container.IsHeld("Player", item) ) + ShockGame.PreventSwap(); + } + } + } + + function OnFrobInvEnd() + { + ShockGame.AddTranslatableText("HelpRecycler", "misc", "Player"); + } +} + +// door base class (does nothing) +class DoorBase extends SqRootScript +{ +} + +class StdDoor extends DoorBase +{ + // static constant + static StateTags = [ "Open", "Closed", "Opening", "Closing", "Halted" ]; + +// METHODS: + + function StateChangeTags() + { + local Status = message().ActionType; + local OldStatus = message().PrevActionType; + + local retval = "Event StateChange, OpenState " + StateTags[Status] + ", OldOpenState " + StateTags[OldStatus]; + + if (OldStatus != eDoorStatus.kDoorHalt && IsDataSet("PlayerFrob")) + retval = retval + ", CreatureType Player"; + + if (Status != eDoorStatus.kDoorClosing && Status != eDoorStatus.kDoorOpening) + ClearData("PlayerFrob"); + + return retval; + } + + function SetCloseTimer() + { + if (Door.GetDoorState( self ) != eDoorStatus.kDoorClosing) + { + local iStayOpenTime = GetProperty("DoorTimer"); + if (iStayOpenTime != 0) + { + if ( IsDataSet("Timer") ) + KillTimer( GetData("Timer") ); + + SetData("Timer", SetOneShotTimer("DoorClose", iStayOpenTime)); + } + } + } + +// MESSAGES: + + function OnTurnOn() + { + if ( IsDataSet("Timer") ) + KillTimer( GetData("Timer") ); + + Door.OpenDoor( self ); + + if (Door.GetDoorState( self ) == eDoorStatus.kDoorOpen) + SetCloseTimer(); + } + + function OnTurnOff() + { + if ( IsDataSet("Timer") ) + KillTimer( GetData("Timer") ); + + SetData("Timer", SetOneShotTimer("DoorClose", 3)); + } + + function OnDoorOpening() + { + if ( !message().isProxy ) + { + Link.BroadcastOnAllLinks(self, "TurnOn", "SwitchLink"); + SetCloseTimer(); + } + + Sound.PlayEnvSchemaNet(self, StateChangeTags(), self, 0, eEnvSoundLoc.kEnvSoundOnObj, eSoundNetwork.kSoundNoNetworkSpatial); + } + + function OnDoorClosing() + { + if ( !message().isProxy ) + { + Link.BroadcastOnAllLinks(self, "TurnOff", "SwitchLink"); + SetCloseTimer(); + } + + Sound.PlayEnvSchemaNet(self, StateChangeTags(), self, 0, eEnvSoundLoc.kEnvSoundOnObj, eSoundNetwork.kSoundNoNetworkSpatial); + } + + function OnDoorOpen() + { + Sound.HaltSchema(self, "", 0); + Sound.PlayEnvSchemaNet(self, StateChangeTags(), self, 0, eEnvSoundLoc.kEnvSoundOnObj, eSoundNetwork.kSoundNoNetworkSpatial); + } + + function OnDoorClose() + { + OnDoorOpen(); + } + + function OnFrobWorldEnd() + { + if ( ShockGame.CheckLocked(self, TRUE, message().Frobber) ) + Door.ToggleDoor( self ); + } + + function OnTimer() + { + if (message().name == "DoorClose") + Door.CloseDoor( self ); + } +} + +class HealingStation extends SqRootScript +{ +// MESSAGES: + + function OnFrobWorldEnd() + { + local Frobber = message().Frobber; + + local hp = Property.Get(Frobber, "HitPoints"); + local hpMax = Property.Get(Frobber, "MAX_HP"); + + if (hpMax > hp) + { + if (ShockGame.PayNanites(5) == S_OK) + { + ShockGame.AddTranslatableText("MedBedUse", "misc", "Player"); + + PostMessage(Frobber, "FullHeal"); + + Property.SetSimple(Frobber, "RadLevel", 0); + ShockGame.OverlayChange(kOverlayRadiation, kOverlayModeOff); + + Property.SetSimple(Frobber, "Toxin", 0); + ShockGame.OverlayChange(kOverlayPoison, kOverlayModeOff); + + Sound.PlayEnvSchema(self, "Event Activate", self, 0, eEnvSoundLoc.kEnvSoundAtObjLoc); + } + else + ShockGame.AddTranslatableText("NeedNanites", "misc", "Player"); + } + } +} + +class BeakerScript extends SqRootScript +{ +// MESSAGES: + + function OnFrobToolEnd() + { + if ( Object.InheritsFrom(message().DstObjId, "Worm Piles") ) + { + local beakerArchetype = ShockGame.GetArchetypeName( self ); + + if ( Link.AnyExist("Mutate", beakerArchetype) ) + { + ShockGame.DestroyInvObj( message().SrcObjId ); + + local newArchetype = LinkDest( Link.GetOne("Mutate", beakerArchetype) ); + ShockGame.AddInvObj( Object.Create(newArchetype) ); + } + } + } +} + +class WormPileScript extends SqRootScript +{ +// MESSAGES: + + function OnFrobWorldEnd() + { + if ( ShockGame.HasImplant("Player", eImplant.kImplantWormBlood) ) + { + ShockGame.HealObj("Player", 10); + Sound.PlayAmbient("Player", "hypo02"); + Object.Destroy( self ); + } + } +} + +class TrapSpawn extends SqRootScript +{ +// METHODS: + + function GetSpawnType() + { + local r1 = GetProperty("Spawn", "Rarity 1"); + local r2 = GetProperty("Spawn", "Rarity 2"); + local r3 = GetProperty("Spawn", "Rarity 3"); + local r4 = GetProperty("Spawn", "Rarity 4"); + + local rnd = Data.RandInt(0, r1 + r2 + r3 + r4 - 1); + + local sum = r1; + if (rnd <= sum) return GetProperty("Spawn", "Type 1"); + sum += r2; + if (rnd <= sum) return GetProperty("Spawn", "Type 2"); + sum += r3; + if (rnd <= sum) return GetProperty("Spawn", "Type 3"); + sum += r4; + if (rnd <= sum) return GetProperty("Spawn", "Type 4"); + + return null; + } + + function Spawn(spawnpoint) + { + local type = GetSpawnType(); + if (type) + { + //print("TrapSpawn (" + self + ") spawning a " + type); // DEBUG + + local zeroes = vector(0); + + local obj = Object.BeginCreate(type); + Property.CopyFrom(obj, "EcoType", self); + Object.Teleport(obj, zeroes, zeroes, spawnpoint); + if (Property.Get(spawnpoint, "AI_Patrol", "") == 1) + Property.SetSimple(obj, "AI_Patrol", 1); + Object.EndCreate(obj); + + Link.Create("Spawned", spawnpoint, obj); + + local sfx = Object.BeginCreate("SpawnSFX"); + Object.Teleport(sfx, zeroes, zeroes, spawnpoint); + Object.EndCreate(sfx); + + ShockAI.ValidateSpawn(obj, self); + + return obj; + } + + return 0; + } + +// MESSAGES: + + function OnTurnOn() + { + local supply = GetProperty("Spawn", "Supply"); + + //print("TrapSpawn (" + self + ") TurnOn, supply = " + supply); // DEBUG + + // if limited supply then decrease available amount (0 = unlimited) + if (supply > 0) + SetProperty("Spawn", "Supply", supply == 1 ? -1 : supply-1); + + if (supply != -1) + { + local flags = GetProperty("Spawn", "Flags"); + local spawnpoint = ShockGame.FindSpawnPoint(self, flags); + if (spawnpoint) + { + local obj = Spawn(spawnpoint); + + //print(" found spawnpoint, flags = " + flags + ", spawned obj " + obj); // DEBUG + + if (obj && (flags & eSpawnFlags.kSpawnFlagGotoAlarm)) + { + local dst = message().data; + AI.MakeGotoObjLoc(obj, dst ? dst : "Player", eAIScriptSpeed.kFast); + } + } + + local schema = GetProperty("ObjSoundName"); + if (schema) + Sound.PlaySchemaAmbientNet(self, schema, eSoundNetwork.kSoundNetworkAmbient); + } + } +} + +class TriggerEcology extends SqRootScript +{ +// METHODS: + + function EcoTriggerMaybe() + { + local iMin, iMax, iRand; + + switch ( GetProperty("EcoState") ) + { + case eEcoState.kEcologyNormal: + iMin = GetProperty("Ecology", "Normal Min"); + iMax = GetProperty("Ecology", "Normal Max"); + iRand = GetProperty("Ecology", "Normal Rand"); + break; + + case eEcoState.kEcologyAlert: + iMin = GetProperty("Ecology", "Alert Min"); + iMax = GetProperty("Ecology", "Alert Max"); + iRand = GetProperty("Ecology", "Alert Rand"); + break; + + default: + return; + } + + local iPopulation = ShockGame.CountEcoMatching( GetProperty("EcoType") ); + + //print("TriggerEcology (" + self + ") update (state = " + GetProperty("EcoState") + "), current population is " + iPopulation); // DEBUG + + if (iPopulation < iMax && (iPopulation < iMin || (iRand > 0 && !Data.RandInt(0, iRand)))) + { + //print(" triggering! min/max/rand = " + iMin + " " + iMax + " " + iRand); // DEBUG + + Link.BroadcastOnAllLinksData(self, "TurnOn", "SwitchLink", GetData("victim")); + } + } + + function SetRecoveryTimer(t) + { + KillRecoverTimer(); + SetData("RecoverTime", SetOneShotTimer("Recovery", t)); + } + + function KillRecoverTimer() + { + local hTimer = GetData("RecoverTime"); + if (hTimer != -1) + KillTimer(hTimer); + } + + function SetEcoNormal() + { + SetProperty("EcoState", eEcoState.kEcologyNormal); + } + + function SetEcoTimer() + { + SetData("ecotimer", SetOneShotTimer("Ecology", GetProperty("Ecology", "Period"))); + } + + function AlarmOn() + { + Sound.PlaySchemaAmbient(self, "xer02"); + ShockGame.AddAlarm(GetProperty("Ecology", "Alert Recovery") * 1000); + PostMessage("Player", "KlaxOn"); + } + + function AlarmOff() + { + Sound.PlaySchemaAmbient(self, "xer03"); + ShockGame.RemoveAlarm(); + PostMessage("Player", "KlaxOff"); + } + + function ResetEco() + { + Link.BroadcastOnAllLinks(self, "Reset", "SwitchLink"); + SetEcoNormal(); + AlarmOff(); + Networking.Broadcast(self, "NetClearAlarm"); + } + +// MESSAGES: + + function OnBeginScript() + { + if ( !Networking.IsProxy( self ) ) + { + SetEcoTimer(); + if ( !IsDataSet("RecoverTime") ) + SetData("RecoverTime", -1); + } + } + + function OnNetAlarm() + { + AlarmOn(); + } + + function OnNetClearAlarm() + { + AlarmOff(); + } + + function OnAlarm() + { + if (GetProperty("EcoState") == eEcoState.kEcologyNormal) + { + SetRecoveryTimer( GetProperty("Ecology", "Alert Recovery") ); + Link.BroadcastOnAllLinksData(self, "Alarm", "SwitchLink", message().data); + SetData("victim", message().data); + SetProperty("EcoState", eEcoState.kEcologyAlert); + AlarmOn(); + Networking.Broadcast(self, "NetAlarm"); + } + } + + function OnReset() + { + if (GetProperty("EcoState") == eEcoState.kEcologyAlert) + { + Link.BroadcastOnAllLinksData(self, "Reset", "SwitchLink", message().data); + SetEcoNormal(); + AlarmOff(); + Networking.Broadcast(self, "NetClearAlarm"); + KillRecoverTimer(); + } + } + + function OnTimer() + { + if (message().name == "Ecology") + { + EcoTriggerMaybe(); + SetEcoTimer(); + } + else if (message().name == "Recovery") + ResetEco(); + } +} + +class TriggerEcologyDiff extends TriggerEcology +{ +// METHODS: + + function EcoTriggerMaybe() + { + local iMin, iMax, iRand; + local iref = int_ref(); + + switch ( GetProperty("EcoState") ) + { + case eEcoState.kEcologyNormal: + iMin = GetProperty("Ecology", "Normal Min"); + iMax = GetProperty("Ecology", "Normal Max"); + iRand = GetProperty("Ecology", "Normal Rand"); + if ( ShockGame.ConfigIsDefined("no_spawn") ) + iMax = 0; + if ( ShockGame.ConfigGetInt("lower_spawn_min", iref) ) + iMin -= iref.tointeger(); + if (iMin < 0); + iMin = 0; + if ( ShockGame.ConfigGetInt("raise_spawn_rand", iref) ) + iRand += iref.tointeger(); + break; + + case eEcoState.kEcologyAlert: + iMin = GetProperty("Ecology", "Alert Min"); + iMax = GetProperty("Ecology", "Alert Max"); + iRand = GetProperty("Ecology", "Alert Rand"); + break; + + default: + return; + } + + if (Quest.Get("Difficulty") == 1) + { + if (iMin > 1) + iMin--; + if (iMax > 1) + iMax--; + iRand *= 2; + } + + local iPopulation = ShockGame.CountEcoMatching( GetProperty("EcoType") ); + + //print("TriggerEcologyDiff (" + self + ") update (state = " + GetProperty("EcoState") + "), current population is " + iPopulation); // DEBUG + + if (iPopulation < iMax && (iPopulation < iMin || (iRand > 0 && !Data.RandInt(0, iRand)))) + { + //print(" triggering! min/max/rand = " + iMin + " " + iMax + " " + iRand); // DEBUG + + Link.BroadcastOnAllLinksData(self, "TurnOn", "SwitchLink", GetData("victim")); + } + } + + function SetEcoTimer() + { + local t = GetProperty("Ecology", "Period"); + + if (Quest.Get("Difficulty") == 1) + t *= 2 + + SetData("ecotimer", SetOneShotTimer("Ecology", t)); + } +} + +class RootPsi extends SqRootScript +{ +// METHODS: + + function ActivatePsi() + { + local power, type; + local scriptdonor = ShockObj.FindScriptDonor(self, GetClassName()); + + SetData("MetaPropID", scriptdonor); + + SetData("Power", power = Property.Get(scriptdonor, "PsiPower", "Power")); + SetData("Type", type = Property.Get(scriptdonor, "PsiPower", "Type")); + SetData("Data1", Property.Get(scriptdonor, "PsiPower", "Data 1")); + SetData("Data2", Property.Get(scriptdonor, "PsiPower", "Data 2")); + SetData("Data3", Property.Get(scriptdonor, "PsiPower", "Data 3")); + SetData("Data4", Property.Get(scriptdonor, "PsiPower", "Data 4")); + + local stat = ShockGame.GetStat(self, eStats.kStatPsi); + if ( ShockPsi.IsOverloaded(power) ) + stat += 2; + SetData("PsiStat", stat); + + if (type == ePsiPowerType.kPsiTypeShield) + SetData("EndHandle", SetOneShotTimer("ShutDown", ShockPsi.GetActiveTime(power), power)); + } + + function DeactivatePsi() + { + ShockPsi.OnDeactivate( GetData("Power") ); + } + + function ClearShieldTimer() + { + if ( IsDataSet("EndHandle") ) + { + KillTimer( GetData("EndHandle") ); + ClearData("EndHandle"); + } + } + +// MESSAGES: + + function OnBeginScript() + { + if (self == ObjID("Player")) + ActivatePsi(); + } + + function OnEndScript() + { + ClearShieldTimer(); + } + + function OnTimer() + { + if (message().name == "ShutDown") + { + if (self == ObjID("Player") && message().data == GetData("Power")) + { + ClearData("EndHandle"); + DeactivatePsi(); + } + } + } + + // there is no "LevelExit" message, but there is however an "EndLevel" message + // (the game deactivates active psi before level transition so don't need to handle that anyway + function OnLevelExit() + { + if (self == ObjID("Player")) + { + ClearShieldTimer(); + DeactivatePsi(); + } + } + + function OnDeactivatePsi() + { + if (self == ObjID("Player") && message().data == GetData("Power")) + { + ClearShieldTimer(); + DeactivatePsi(); + } + } +} + +class Immolate extends RootPsi +{ +// METHODS: + + function ActivatePsi() + { + base.ActivatePsi(); + + SetData("pyroFX", Object.Create("Localized Pyro")); + } + + function DeactivatePsi() + { + base.DeactivatePsi(); + + Object.Destroy( GetData("pyroFX") ); + } +} + + +// intercepts all messages sent to all script instances from this OSM +// primarily intended for debugging, do not declare this function unless absolutely needed, +// because it can have a negative impact on performance if there are many script instances +/*function PreFilterMessage(message) +{ + print("PreFilterMessage: \"" + message.message + "\" " + message.from + " -> " + message.to); + + // if it's necessary to set a message reply then the Reply functions from SqRootScript can be used + //SqRootScript.Reply( .. ); + + // return 'true' if message should be intercepted and not sent to the script instance it was intended for (be careful when doing this) + return false; +}*/ diff --git a/DOC/squirrel_script/samples/T2OverlaySample.nut b/DOC/squirrel_script/samples/T2OverlaySample.nut new file mode 100644 index 0000000..258f515 --- /dev/null +++ b/DOC/squirrel_script/samples/T2OverlaySample.nut @@ -0,0 +1,363 @@ +// A squirrel.osm port of the C++ T2 overlay sample ("t2_overlay_sample.cpp"). +// This can be used in place of "t2sample.osm" with the "Demo_OverlaySampleOsm.mis" mission. + +// +// Local helper classes for individual HUD elements etc. +// +// + +class sRect +{ + left = 0; + top = 0; + right = 0; + bottom = 0; + + function IsPtInside(x, y) { return x >= left && y >= top && x < right && y < bottom; } +} + +// --------------------------------------------------------------- + +// base class for our HUD elements +class cHudElement +{ + m_bActive = false; + m_rect = null; + + constructor() + { + m_rect = sRect(); + } + + function Toggle() { m_bActive = !m_bActive; } + function Show() { m_bActive = true; } + function Hide() { m_bActive = false; } + + // required functions in derived classes + //function CalcPlacement() + //function Draw() +} + + +// a custom HUD element +class cHudElement_Something extends cHudElement +{ + m_bgImage = 0; + + // keep two temp int_ref objects around which are useful when calling service functions that have integer reference parameters + // (avoids constant object creation and deletion if these were created as temp local vars) + iref1 = int_ref(); + iref2 = int_ref(); + + constructor() + { + base.constructor(); + + m_bgImage = DarkOverlay.GetBitmap("sima_1"); + + CalcPlacement(); + } + + function CalcPlacement() + { + m_rect.left = 0; + m_rect.top = 0; + + DarkOverlay.GetBitmapSize(m_bgImage, iref1, iref2); + m_rect.right = iref1.tointeger(); + m_rect.bottom = iref2.tointeger(); + + m_rect.right += m_rect.left; + m_rect.bottom += m_rect.top; + } + + function Draw() + { + DarkOverlay.DrawBitmap(m_bgImage, m_rect.left, m_rect.top); + } +} + +// another custom HUD element +class cHudElement_SomethingElse extends cHudElement +{ + m_bgImage = 0; + + // keep two temp int_ref objects around which are useful when calling service functions that have integer reference parameters + // (avoids constant object creation and deletion if these were created as temp local vars) + iref1 = int_ref(); + iref2 = int_ref(); + + constructor() + { + base.constructor(); + + m_bgImage = DarkOverlay.GetBitmap("demof009"); + + CalcPlacement(); + } + + function CalcPlacement() + { + Engine.GetCanvasSize(iref1, iref2); + local w = iref1.tointeger(); + local h = iref2.tointeger(); + + DarkOverlay.GetBitmapSize(m_bgImage, iref1, iref2); + local bm_w = iref1.tointeger(); + local bm_h = iref2.tointeger(); + + m_rect.left = w-bm_w; + m_rect.top = 0; + m_rect.right = m_rect.left + bm_w; + m_rect.bottom = m_rect.top + bm_h; + } + + function Draw() + { + DarkOverlay.DrawBitmap(m_bgImage, m_rect.left, m_rect.top); + + DarkOverlay.GetStringSize("H", iref1, iref2); + local w = iref1.tointeger(); + local h = iref2.tointeger(); + + DarkOverlay.DrawString("Hello", m_rect.left+2, m_rect.top+4); + DarkOverlay.DrawString("World", m_rect.left+2, m_rect.top+4+h+2); + } +} + +// --------------------------------------------------------------- + +// base class for our transparent (non-interactive) overlay elements +class cOverlayElement +{ + m_bActive = false; + m_handle = -1; + + constructor() + { + } + + function Toggle() { m_bActive = !m_bActive; } + function Show() { m_bActive = true; } + function Hide() { m_bActive = false; } + + // required functions in derived classes + //function CalcPlacement() + //function Draw() +} + + +// a custom overlay element +class cOverlayElement_Something extends cOverlayElement +{ + x = 0; + y = 0; + + constructor() + { + base.constructor(); + + // create a simple static overlay of a bitmap + + CalcPos(); + + // you cannot use images that are potentially also used as textures on 3D objects/terrain + // this is only used here for demo purposes + local bm = DarkOverlay.GetBitmap("clocger3", "obj\\txt16\\"); + + m_handle = DarkOverlay.CreateTOverlayItemFromBitmap(x, y, 127, bm, TRUE); + } + + function CalcPos() + { + // if desired do some fancy position calcs based on canvas size for different alignments + x = 10; + y = 10; + } + + function CalcPlacement() + { + CalcPos(); + + DarkOverlay.UpdateTOverlayPosition(m_handle, x, y); + } + + function Draw() + { + DarkOverlay.DrawTOverlayItem(m_handle); + } +} + +// another custom overlay element +class cOverlayElement_SomethingElse extends cOverlayElement +{ + m_bUpdateContents = false; + m_bgImage = 0; + x = 0; + y = 0; + + // keep two temp int_ref objects around which are useful when calling service functions that have integer reference parameters + // (avoids constant object creation and deletion if these were created as temp local vars) + iref1 = int_ref(); + iref2 = int_ref(); + + constructor() + { + base.constructor(); + + // create a dynamic/comlpex overlay with 64x64 size + + CalcPos(); + + m_handle = DarkOverlay.CreateTOverlayItem(x, y, 128, 128, 127, TRUE); + m_bUpdateContents = true; + + // get our bg bitmap + m_bgImage = DarkOverlay.GetBitmap("p003r001", "intrface\\miss1\\english\\"); + } + + function CalcPos() + { + // if desired do some fancy position calcs based on canvas size for different alignments + x = 10; + y = 300; + } + + function CalcPlacement() + { + CalcPos(); + + DarkOverlay.UpdateTOverlayPosition(m_handle, x, y); + } + + function Draw() + { + // draw overlay contents to update it if something changed + if (m_bUpdateContents) + { + m_bUpdateContents = false; + + if ( DarkOverlay.BeginTOverlayUpdate(m_handle) ) + { + local s = "miss" + DarkGame.GetCurrentMission(); + + DarkOverlay.GetStringSize(s, iref1, iref2); + local w = iref1.tointeger(); + local h = iref2.tointeger(); + + DarkOverlay.DrawBitmap(m_bgImage, 0, 0); + DarkOverlay.DrawString(s, 128-w-4, 4); + + DarkOverlay.EndTOverlayUpdate(); + } + } + + DarkOverlay.DrawTOverlayItem(m_handle); + } +} + + +/****************************************************************************/ + + +// +// The overlay handler interface +// Receives calls from the engine. Only one handler (per OSM) can be active at a time. +// + +class cMyThiefOverlay extends IDarkOverlayHandler +{ + m_elems = null; + m_overlays = null; + + /*constructor() + { + base.constructor(); + }*/ + + function Init() + { + m_elems = []; + m_elems.append( cHudElement_Something() ); + m_elems.append( cHudElement_SomethingElse() ); + + m_overlays = []; + m_overlays.append( cOverlayElement_Something() ); + m_overlays.append( cOverlayElement_SomethingElse() ); + + // show em all + + foreach (o in m_elems) + o.Show(); + + foreach (o in m_overlays) + o.Show(); + } + + function Term() + { + foreach (i, o in m_elems) + m_elems[i] = null; + + foreach (i, o in m_overlays) + m_overlays[i] = null; + } + + // + // IDarkOverlayHandler interface + // + + function DrawHUD() + { + foreach (o in m_elems) + if (o.m_bActive) + o.Draw(); + } + + function DrawTOverlay() + { + foreach (o in m_overlays) + if (o.m_bActive) + o.Draw(); + } + + function OnUIEnterMode() + { + foreach (o in m_elems) + o.CalcPlacement(); + + foreach (o in m_overlays) + o.CalcPlacement(); + } +} + + +myOverlay <- cMyThiefOverlay(); + + +// +// Script that installs and uninstalls the overlay handler +// Add this script to one (dummy) object in the mission +// + +class MyHudScript extends SqRootScript +{ + function destructor() + { + // to be on the safe side make really sure the handler is removed when this script is destroyed + // (calling RemoveHandler if it's already been removed is no problem) + DarkOverlay.RemoveHandler(myOverlay); + } + + function OnBeginScript() + { + DarkOverlay.AddHandler(myOverlay); + myOverlay.Init(); + } + + function OnEndScript() + { + DarkOverlay.RemoveHandler(myOverlay); + myOverlay.Term(); + } +} diff --git a/DOC/squirrel_script/samples/T2_samples.nut b/DOC/squirrel_script/samples/T2_samples.nut new file mode 100644 index 0000000..914f094 --- /dev/null +++ b/DOC/squirrel_script/samples/T2_samples.nut @@ -0,0 +1,402 @@ +// squirrel.osm versions of some gen.osm scripts + + +// Changes your MAP_MAX_PAGE quest variable to the page on the object's Automap property, then brings up your automap +class MapSupplement extends SqRootScript +{ +// MESSAGES: + + // handle "FrobInvEnd" messages (message class is "sFrobMsg", see "API-reference_message.txt") + function OnFrobInvEnd() + { + // get value of quest var "map_max_page" + local oldmax = Quest.Get("map_max_page"); + + // see if this object has the property "Automap" + if ( HasProperty("Automap") ) + { + // get the value of the "Page" field in the "Automap" property + local newmax = GetProperty("Automap", "Page"); + + // update the "map_max_page" quest var with the new value from the property, if it's larger than before + if(newmax > oldmax) + Quest.Set("map_max_page", newmax); + } + + // execute the "automap" command + Debug.Command("automap"); + } +} + +// Musical instrument: make a sound (on yourself) if world frobbed or inv frobbed +class Instrument extends SqRootScript +{ +// MESSAGES: + + // handle "FrobWorldEnd" messages (message class is "sFrobMsg") + function OnFrobWorldEnd() + { + // play sound schema + Sound.PlayEnvSchema(self, "Event Activate", self, message().Frobber, eEnvSoundLoc.kEnvSoundAtObjLoc); + } + + // handle "FrobInvEnd" messages (message class is "sFrobMsg") + function OnFrobInvEnd() + { + // play sound schema + Sound.PlayEnvSchema(self, "Event Activate", self, message().Frobber, eEnvSoundLoc.kEnvSoundAtObjLoc); + } +} + +// For healing potions: when I am frobbed, make an unreffed clone of myself and put it in A/R contact with the frobber. +class CloneContactFrob extends SqRootScript +{ +// MESSAGES: + + // handle "FrobInvEnd" messages (message class is "sFrobMsg") + function OnFrobInvEnd() + { + // create a new object that is a clone of this object + local newobj = Object.BeginCreate(self); + // set the "HasRefs" property to FALSE on the clone + Property.Set(newobj, "HasRefs", FALSE); + Object.EndCreate(newobj); + + // begin a stim contact between the clone and frobber + ActReact.BeginContact(newobj, message().Frobber); + } +} + +// Similar to the above: when I take damage from VenomStim, clone the weapon responsible and put me in contact with it, causing continuing damage. +class CloneContactDmg extends SqRootScript +{ +// MESSAGES: + + // handle "Damage" messages (message class is "sDamageScrMsg") + function OnDamage() + { + // see if damage type is "VenomStim" + if (message().kind == ObjID("VenomStim")) + { + // create a new object that is a clone of the object causing the damage + local newobj = Object.BeginCreate(message().culprit); + Property.Set(newobj, "HasRefs", FALSE); + Object.EndCreate(newobj); + + // begin a stim contact between the clone and this object + ActReact.BeginContact(newobj, self); + } + } +} + +// For object which is slain when frobbed +class FrobSlay extends SqRootScript +{ +// MESSAGES: + + function OnFrobWorldEnd() + { + // slay this object, with frobber as the culprit + Damage.Slay(self, message().Frobber); + } +} + +// Manages adding and removing the A/R heat source on flames +class DoFlameSource extends SqRootScript +{ +// MESSAGES: + + // handle "Sim" messages (message class is "sSimMsg") + function OnSim() + { + // check that it's a sim start message + if (message().starting) + { + // add the "FlameHeatSource" metaproperty to this object + Object.AddMetaProperty(self, "FlameHeatSource"); + } + } + + // handle "Slain" messages + function OnSlain() + { + // remove the "FlameHeatSource" metaproperty from this object + Object.RemoveMetaProperty(self, "FlameHeatSource"); + } +} + +class EatFood extends SqRootScript +{ +// MESSAGES: + + function OnFrobInvEnd() + { + local Frobber = message().Frobber; + local is_player = (Frobber == ObjID("Player")); + local loc = is_player ? eEnvSoundLoc.kEnvSoundAmbient : eEnvSoundLoc.kEnvSoundAtObjLoc; + + local zeroes = vector(0); + + // move the object to the frobber + Object.Teleport(self, zeroes, zeroes, Frobber); + + Sound.PlayEnvSchema(self, "Event Activate", self, Frobber, loc); + } +} + +class HolyH2O extends EatFood +{ +// MESSAGES: + + function OnFrobInvEnd() + { + local Frobber = message().Frobber; + local invent = Link.GetAll("Contains", Frobber); + local haswater = false; + local water = ObjID("water"); + + foreach (link in invent) + { + if ( Object.InheritsFrom(LinkDest(link), water) ) + { + haswater = true; + break; + } + } + + if (haswater) + PostMessage(Frobber, "Sanctify"); + else + Reply(0); + + base.OnFrobInvEnd(); + } + +} + +class Arrow extends SqRootScript +{ +// METHODS: + + function UnEquipMe() + { + Bow.UnEquip(); + DrkInv.RemoveSpeedControl("BowDraw"); + } + +// MESSAGES: + + function OnBeginScript() + { + if ( !IsDataSet("Selected") ) + SetData("Selected", FALSE); + } + + function OnFrobInvBegin() + { + if (message().Abort) + { + Bow.AbortAttack(); + DrkInv.RemoveSpeedControl("BowDraw"); + } + else + { + Bow.StartAttack(); + DrkInv.AddSpeedControl("BowDraw", 0.75, 1.0); + } + } + + function OnFrobInvEnd() + { + local retval = Bow.FinishAttack(); + DrkInv.RemoveSpeedControl("BowDraw"); + Reply(retval); + } + + function OnInvSelect() + { + SetData("Selected", TRUE); + Bow.SetArrow(self); + Bow.Equip(); + } + + function OnInvDeSelect() + { + SetData("Selected", FALSE); + UnEquipMe(); + } + + function OnDestroy() + { + if ( GetData("Selected") ) + UnEquipMe(); + } +} + +class LootSounds extends RootScript +{ +// MESSAGES: + + function OnContained() + { + if (message().event != eContainsEvent.kContainRemove + && message().container == ObjID("Player") + && GetTime() > 0.1) + { + local schem; + + if ( Object.InheritsFrom(self, "IsLoot") ) + schem = "pickup_loot"; + else + schem = "pickup_power"; + + if (schem) + Sound.PlaySchemaAmbient(self, schem); + } + } +} + +class Legible extends SqRootScript +{ +// METHODS: + + function ShowText() + { + if ( HasProperty("book") ) + { + local bookname = GetProperty("book"); + + if ( HasProperty("TrapQVar") ) + { + local qvar = GetProperty("TrapQVar"); + local index = Quest.Get(qvar); + + bookname += format("%02d", index); + } + + if ( HasProperty("bookart") ) + { + local bookart = GetProperty("bookart"); + DarkUI.ReadBook(bookname, bookart); + } + else + { + local popup = Data.GetString(bookname, "Page_0", "", "Books"); + DarkUI.TextMessage(popup); + } + } + } +} + +class StdBook extends Legible +{ +// MESSAGES: + + function OnFrobWorldEnd() + { + ShowText(); + } +} + +class StdScroll extends Legible +{ +// MESSAGES: + + function OnFrobInvEnd() + { + ShowText(); + } +} + +class BlackJack extends SqRootScript +{ +// MESSAGES: + + function OnFrobInvBegin() + { + if (message().Abort) + Weapon.FinishAttack(message().Frobber, message().SrcObjId); + else + Weapon.StartAttack(message().Frobber, message().SrcObjId); + } + + function OnFrobInvEnd() + { + Weapon.FinishAttack(message().Frobber, message().SrcObjId); + } + + function OnFrobToolBegin() + { + Weapon.StartAttack(message().Frobber, message().SrcObjId); + } + + function OnFrobToolEnd() + { + Weapon.FinishAttack(message().Frobber, message().SrcObjId); + } + + function OnInvSelect() + { + Weapon.Equip(self, eDarkWeaponType.kDWT_BlackJack); + } + + function OnInvDeSelect() + { + Weapon.UnEquip(self); + } +} + +class Sword extends SqRootScript +{ +// MESSAGES: + + function OnFrobInvBegin() + { + if (message().Abort) + Weapon.FinishAttack(message().Frobber, message().SrcObjId); + else + Weapon.StartAttack(message().Frobber, message().SrcObjId); + } + + function OnFrobInvEnd() + { + Weapon.FinishAttack(message().Frobber, message().SrcObjId); + } + + function OnFrobToolBegin() + { + Weapon.StartAttack(message().Frobber, message().SrcObjId); + } + + function OnFrobToolEnd() + { + Weapon.FinishAttack(message().Frobber, message().SrcObjId); + } + + function OnInvSelect() + { + Weapon.Equip(self, eDarkWeaponType.kDWT_Sword); + DrkInv.AddSpeedControl("SwordEquip", 0.75, 0.8); + } + + function OnInvDeSelect() + { + Weapon.UnEquip(self); + DrkInv.RemoveSpeedControl("SwordEquip"); + } +} + + +// intercepts all messages sent to all script instances from this OSM +// primarily intended for debugging, do not declare this function unless absolutely needed, +// because it can have a negative impact on performance if there are many script instances +/*function PreFilterMessage(message) +{ + print("PreFilterMessage: \"" + message.message + "\" " + message.from + " -> " + message.to); + + // if it's necessary to set a message reply then the Reply functions from SqRootScript can be used + //SqRootScript.Reply( .. ); + + // return 'true' if message should be intercepted and not sent to the script instance it was intended for (be careful when doing this) + return false; +}*/ From 29f3bb70ec3da5c00def7ee7386579666faafcc8 Mon Sep 17 00:00:00 2001 From: Daniel Sperber Date: Wed, 5 Aug 2026 16:01:51 +0000 Subject: [PATCH 3/9] cleanup: strip debug prints from DScript Core Development traces that reached players: with kUseIngameLog the log tail is drawn on screen, otherwise they spam monolog.txt / Thief2.log. - DCheckString: remove the unconditional prints in the '/' ping-back, '>' file and radius branches (hottest function in the framework). Where a print was the only body of a loop or if, the scaffolding goes with it; the FindFileInPath branches keep their structure and get TODO comments. - DBaseTrap constructor: drop the never-true DTrigger compare and its print. - DBaseFunction: "_script NOT SET" now goes through DPrint(..., kDoPrint, ePrintTo.kMonolog) instead of a bare print. - DScript.SetQVar: comment out the ungated INFO print. The string was built on every QVar write. - DScriptHandler: remove the stray self/MissionInitialized/Object.Exists prints. - DTrapSetQVar: remove the DID BEGIN/DID SIM/InitQVarFromProp traces and the OnBeginScript override that existed only for its print. Its two "Setting x to y" DPrints lose kDoPrint, so they honour Debug like the rest of the framework - the first one defaulted to mode kMonolog|kUI and wrote an on-screen message in the shipped game. - DScript.Quest: remove the per-subscribe and per-QVar-change traces. - DTrigQVar.OnDarkGameModeChange: print replaced by a comment; the empty handler is kept on purpose so the message is not passed on to OnMessage. No gameplay behaviour changed. Refs T-60, T-61, T-62, T-67. --- DScript Core.nut | 39 +++++++++------------------------------ 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/DScript Core.nut b/DScript Core.nut index c69265e..5ba7671 100644 --- a/DScript Core.nut +++ b/DScript Core.nut @@ -824,9 +824,9 @@ DScript <- { # |-- Test if the given type can be used type = ::DScript._DoQVarChecks(name, type, value) - # DEBUG POINT - DPrint("\nINFO: Saving '" + name + "' with value '" + value + "'("+typeof value+") with type level '" - + (type == eQuestDataType.kQuestDataUnknown?eQuestDataType.kQuestDataMission:type) +"'", kDoPrint, ePrintTo.kMonolog) + # DEBUG POINT - QVar write hot path, keep quiet. Uncomment while debugging QVar storage: + //DPrint("\nINFO: Saving '" + name + "' with value '" + value + "'("+typeof value+") with type level '" + //+ (type == eQuestDataType.kQuestDataUnknown?eQuestDataType.kQuestDataMission:type) +"'", kDoPrint, ePrintTo.kMonolog) # |-- Save by type. switch(type){ @@ -1219,7 +1219,6 @@ SubVersion = 0.72 origin = message().data3 } # Get First Object Set -print(str+"start?" + start + " On: " + self) local division = ::DScript.DivideAtNext(str, "/", true) if (division[1] != ""){ // we are not at the end local nextset = DCheckString(division[0], kReturnArray) @@ -1238,8 +1237,6 @@ print(str+"start?" + start + " On: " + self) if (!start) return false - foreach (obj in ::gSHARED_SET) -print("SHARED" + obj) return ::DScript._FormatForReturn(delete ::gSHARED_SET, returnInArray) // TODO @@ -1286,12 +1283,12 @@ print("SHARED" + obj) local sref = ::string() if (::Engine.FindFileInPath("install_path", divide[2], sref)) // TODO cache location, check FM { - print("yes in " + sref) + // TODO: use the resolved path in sref. } else { - print("nope try again") + // TODO: not found in install_path - falls through to the unvalidated path below. } /* @@ -1423,7 +1420,6 @@ print("SHARED" + obj) } if (raw.len() == 2){ // if still two items exist it must be >radius values[1] = raw[1].tofloat() - print(divide[0]) if (divide[0][1] == '>') // divide[0] is the part before the colon {>5...: values[0] = true } @@ -1593,8 +1589,6 @@ SourceObj = null // The actual source of a message. // In the constructor() it handles the necessary ObjectData needed for Counters and Capacitors. constructor(){ // Setting up save game persistent data. _script = GetClassName() // base.constructor has to be called before using _script. - if (this.getclass().getbase() == "DTrigger") - print("yohoho") //print("Constructed" + _script + " On " + self + DScript.GetObjectName(Object.Archetype(self))) if (!::IsEditor()){ // Initial data is set in the Editor. return @@ -1830,7 +1824,7 @@ SourceObj = null // The actual source of a message. // print("CURRENT Copy" + _script) if (_script == null) - print(GetClassName() +" on " + self + "_script NOT SET! - base.constructor probably missing.") + DPrint("_script NOT SET! - base.constructor probably missing.", kDoPrint, ePrintTo.kMonolog) return RepeatForCopies(::callee(), DN) } @@ -2215,7 +2209,6 @@ if (IsEditor()){ Property.Set(core,"SlayResult","Effect", eSlayResult.kSlayDestroy) Object.Teleport(core,vector(4,4,4),vector()) - print("I'm " + self) print("DScript - Creating Handler Object. " + core) Object.EndCreate(core) } @@ -2330,7 +2323,6 @@ class DScriptHandler extends DRelayTrap ::Quest.BinDelete("MissBinTables") } SetData("MissionInitialized") - print("MissionInitialized") } } @@ -2514,7 +2506,6 @@ class DScriptHandler extends DRelayTrap function OnDelete(){ DPrint("WARNING. DScript Handler deleted. This might delete some script data.\nWill recreate another instance.", kDoPrint, ePrintTo.kMonolog | ePrintTo.kLog) - print(Object.Exists("DScriptHandler")) } // |-- Destructor @@ -2766,33 +2757,24 @@ class DTrapSetQVar extends DBaseTrap } } - function OnBeginScript(){ - ::print("DID BEGIN") - base.OnBeginScript() - } - function InitQVarFromProp(){ - print(GetProperty("TrapQVar") + " Im " + self) local event = ::split(GetProperty("TrapQVar"),":;") - print("Len of prop "+event.len()) if (event.len() == 1 && event[0].len()){ - print(event[0]) if (event[0] == "\"\"") event[0] == "" else event[0] = DCheckString(event[0]) - DPrint("Setting " + DGetParam(_script + "Name") + " to " + event[0], kDoPrint) + DPrint("Setting " + DGetParam(_script + "Name") + " to " + event[0]) PrepareSetQVar("", event[0]) } else if (event.len() >= 1){ // Set more than one. - print("0 is+ '"+event[0]) event.apply(::strip) // TODO: Do this more. for(local i = 0; i < event.len(); i += 2){ if (event[i+1] == "\"\"") event[i+1] == "" else event[i+1] = DCheckString(event[i+1]) - DPrint("Setting " + event[i] + " to " + event[i+1], kDoPrint, ePrintTo.kMonolog) + DPrint("Setting " + event[i] + " to " + event[i+1]) PrepareSetQVar(event[i],event[i+1]) } } @@ -2802,7 +2784,6 @@ class DTrapSetQVar extends DBaseTrap if (::DHandler.IsDataSet("MissionInizialzed") || !HasProperty("TrapQVar")) return InitQVarFromProp() - ::print("DID SIM") } function DoOn(DN = null) @@ -2820,7 +2801,6 @@ DScript.Quest <- Triggers = {} // will contain instance = array(of values) function SubscribeMsg(instance, var_name){ - print("Saving QVar Trigger" + instance) if (var_name == "*") return Triggers[instance] <- false if (instance in Triggers){ @@ -2861,7 +2841,6 @@ DScript.Quest <- function QuestChange(name, newval, oldval){ /* Checks which triggers shall react to the given msg. */ foreach (trigger, vars in Triggers){ - print(type(trigger) + typeof vars) if (!vars) // "*" all trigger.CheckQuest(name, newval, oldval) else @@ -2921,7 +2900,7 @@ DefOff = null function OnDarkGameModeChange(){ if (!message().suspending && !message().resuming){ - print("MODE CHANGED") + // TODO: nothing to do yet. The handler itself keeps the message from reaching OnMessage. } From f284495800ae8ddb0636775d26ab33725aa5f614 Mon Sep 17 00:00:00 2001 From: Daniel Sperber Date: Wed, 5 Aug 2026 16:02:04 +0000 Subject: [PATCH 4/9] cleanup: strip debug prints from SFX, File&Blob, ModdingTools DScript SFX.nut - DDirector: remove the seven dev prints (speed, link data, waypoint index, "Will not start", "Path[0]", ...). - DUseInventoryMaster.OnContained: fold the message into the DPrint that already gated it, instead of a raw print behind if (DPrint("")). DScript File&Blob.nut - dCSV.createCSVMatrix: remove the per-construction separator print. dump() keeps its prints, printing is what it is for. - DPersistentSaveSimple / DPersistentSave: remove the IsOn, map/name, raw slot data and MissData dumps. - cDSaveHandler.GetSaveRaw: the two slot-shuffling diagnostics are kept but gated behind DMissionDebug, following the DMissionFingerPrint convention a few lines above. - DPersistentSaveTrap: drop kDoPrint from the "Event Data is" DPrint (mode defaulted to kMonolog|kUI, so it wrote an on-screen message in game) and remove the typeof print next to it. DScript_ModdingTools.nut - Remove the leftover scratch snippet at the end of the file. It ran at top level on every compile and printed "yes"/"nope" unconditionally. - The remaining prints in this editor-only file are the tools' own output (DDumpModels progress, DumpTable, DImportObj errors, DPerformanceTest results) and are left alone. No gameplay behaviour changed. Refs T-63. --- DScript File&Blob.nut | 18 ++++++------------ DScript SFX.nut | 10 +--------- DScript_ModdingTools.nut | 7 ------- 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/DScript File&Blob.nut b/DScript File&Blob.nut index f4dda71..16c40ea 100644 --- a/DScript File&Blob.nut +++ b/DScript File&Blob.nut @@ -456,7 +456,6 @@ class dCSV extends dblob // |-- Input Interpretation --| function createCSVMatrix(separator = '\t', commentstring = "//", delimiter = '\''){ - print("separator is " + separator.tochar()) myblob.seek(0,'b') // Make sure pointer is at start do { // This loop is a line local c = myblob[tell()] @@ -583,8 +582,7 @@ DefOff = null } IsOn = Engine.FindFileInPath("install_path", IsOn, string()) - - print(IsOn) + if (IsOn) base.RelayMessages("On") @@ -681,8 +679,6 @@ class cDSaveHandler extends cDCustomHandler name = name.tostring() map = map.tostring() - print("map :" + map) - print("name :" + name) local stamp = "" // first 4 name characters @@ -738,7 +734,8 @@ class cDSaveHandler extends cDCustomHandler if (!slot){ // All slots were used by EnvMaps or other missions. // Check if a slot is not used by a save. - print("Found no slot, but saves avaliable") + if (::DHandler.DGetParamRaw("DMissionDebug")) + print("DScript: DPersistentSave: Found no slot, but saves avaliable") for (local i = 63; i > 55; i--){ if (!(i.tostring() in Saves)){ slot = i @@ -747,7 +744,8 @@ class cDSaveHandler extends cDCustomHandler } // For the current mission the slot shall always be 63 if (slot != 63){ - print("mission save not in 63, moving others down by 1.") + if (::DHandler.DGetParamRaw("DMissionDebug")) + print("DScript: DPersistentSave: mission save not in 63, moving others down by 1.") local temp = {} // Deleting and shifting during a foreach, bad idea use a new table. foreach (idx, save in Saves){ // lower number by 1 @@ -778,7 +776,6 @@ class cDSaveHandler extends cDCustomHandler rawdata = File.slice(File.find(eDLoad.kStart), File.find(eDLoad.kEnd)) foreach (slot, save in Saves){ local data = rawdata.getParam2("Env Zone "+slot,"", 2, 0); // original mission data - print(slot+data) if (data != "") backup[slot] <- data Engine.SetEnvMapZone(slot, Saves[slot]); @@ -791,9 +788,7 @@ class cDSaveHandler extends cDCustomHandler function SetEvent(event_id, value, instantly = true){ assert(value >= 0 && value < 16) - print(MissData) MissData = MissData.slice(0, -event_id) + value + MissData.slice(-event_id + 1) - print(MissData) // TODO also do a backup blob if (instantly) SaveFile() @@ -850,8 +845,7 @@ EventID = null } // Get EventValue local event_data = DSaveHandler.GetEvent(EventID) - DPrint("Event Data is "+ event_data, true) - print(typeof event_data) + DPrint("Event Data is "+ event_data) // Is data not null 0 -> 15 if (event_data >= 0){ // DataMatch does allow some advanced comparison. diff --git a/DScript SFX.nut b/DScript SFX.nut index e6a62d5..9923dc9 100644 --- a/DScript SFX.nut +++ b/DScript SFX.nut @@ -865,8 +865,7 @@ exception = null // Fixes deselection. If an item is picked up that does be function OnContained(){ if (message().event == eContainsEvent.kContainAdd && message().container == ::PlayerID){ local sub = GetInventory() - if (DPrint("")) - print("Hi I'm a " + DScript.GetObjectName(self,true) +" and would like to go to " + DScript.GetObjectName(sub,true) + sub) + DPrint("Would like to go to " + DScript.GetObjectName(sub,true) + sub) if (::Container.IsHeld(OBJ_WILDCARD,sub) == eContainType.ECONTAIN_NULL){ // If the subinventory is not held, move it to the player. //DoOn() exception = true @@ -1440,7 +1439,6 @@ class DDirector extends DObjectPanTo local link = Link.GetOne("ScriptParams", cur_point) target = LinkDest(link) speed = LinkTools.LinkGetData(link, "").tofloat() - print("Speed is" + speed) if (!speed) speed = DGetParam(_script + "PanSpeed", 3) } @@ -1474,7 +1472,6 @@ class DDirector extends DObjectPanTo local idx = message().data foreach(link in Link.GetAll("ScriptParams", self)){ local data = LinkTools.LinkGetData(link, "") - ::print("data is " + data) if (data == null) continue if (::abs(data.tointeger()) == idx && (data[0] >= '0' || (!leave && data[0] == '+') || (leave && data[0] == '-'))) @@ -1513,12 +1510,10 @@ class DDirector extends DObjectPanTo local speed = LinkTools.LinkGetData(next_link, "Speed") if (speed <= 0){ Property.Set(self,"MovingTerrain","active",FALSE); - print("speec" + speed) if (speed == 0) SetData("Jump",Path[index + 1]) else LinkTools.LinkSetData(next_link, "Speed", -speed) - print("Will not start") base.OnMessage() return false // Stops } @@ -1541,7 +1536,6 @@ class DDirector extends DObjectPanTo Link.Create("TPathNext", self, Path[GetData("Active")+2]) else return - ::print("next obj is " + Path[GetData("Active")+2]) if (!OnMovingTerrainWaypoint()) return // Pause } @@ -1648,7 +1642,6 @@ class DDirector extends DObjectPanTo } } else ClearData("ReachedEnd") - ::print("Cur idx = "+GetData("Active") +" len: " + Path.len()) if (notcanceled) SendMessage(self, "ReachedEndpoint", ClearData("Active"), TRUE, Path.top()) else @@ -1656,7 +1649,6 @@ class DDirector extends DObjectPanTo SendMessage(self, "Canceled", GetData("Active"), null, Path[ClearData("Active")]) } Link.Destroy(Link.GetOne("TPathNext", self)) - ::print("Path[0]") Object.Teleport(self, vector(), vector(), Path[0]) Link.Create("TPathNext",self,Path[1]) diff --git a/DScript_ModdingTools.nut b/DScript_ModdingTools.nut index ecc95a0..c33eb61 100644 --- a/DScript_ModdingTools.nut +++ b/DScript_ModdingTools.nut @@ -1127,10 +1127,3 @@ while (Object.Archetype(obj) != 0){ obj++ }*/ - -local s = "alxarm" -foreach (signal in getconsttable().eAlarmSignals){ - if (s == signal) - return print("yes") - } - print("nope") \ No newline at end of file From 2ca17b2009eca122156d2b6e45beb0f0dfa154bb Mon Sep 17 00:00:00 2001 From: Daniel Sperber Date: Wed, 5 Aug 2026 16:04:34 +0000 Subject: [PATCH 5/9] chore: dedupe config layers, fix .gitignore - DSConfigDefault.nut: remove the Auto Texture Replacement block (lines 117-227). It was a verbatim duplicate of DSConfigDefAutoTxt.nut, which means enum eDAutoTxtRepl was declared twice in the const table and gDModTable/gDTexTable were built twice on every load. The dedicated file is the one that stays; a pointer comment is left behind. The only content the duplicate had that the dedicated file lacked - the note that FindFileInPath does not check subfolders - is ported over. - DSConfigMyFM.nut: kReplyMessage was declared const here and in DSConfigFix.nut. The Fix layer keeps the declaration, this file now shows the override syntax as a comment, like DSConfigFix Example.nut does. - .gitignore: backup\ / obj\ used backslashes, so neither directory was actually ignored. Now backup/ and obj/. Refs T-02, T-03, T-05. --- .gitignore | 8 ++- DSConfigDefAutoTxt.nut | 2 +- DSConfigDefault.nut | 113 ++--------------------------------------- DSConfigMyFM.nut | 6 ++- 4 files changed, 16 insertions(+), 113 deletions(-) diff --git a/.gitignore b/.gitignore index ef2ea48..2e47db7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ -backup\ -obj\ \ No newline at end of file +backup/ +obj/ + +# Claude Code — personal, not shared (.claude/settings.json stays tracked) +.claude.local.md +.claude/settings.local.json diff --git a/DSConfigDefAutoTxt.nut b/DSConfigDefAutoTxt.nut index 681a276..e960cb3 100644 --- a/DSConfigDefAutoTxt.nut +++ b/DSConfigDefAutoTxt.nut @@ -104,7 +104,7 @@ gDTexTable <- "2":["jbg-pillow-$",["c","g","d","b","b","g","d","d","b","h","c","g","b","d","b","h","b","e","b","d","h","b","c","c","e","b","c","h"],"jbg-pillow-$",["b","c","d","e","f","g","h","h","g","g","e","b","c","f","f","b","c","b","e","e","g","e","h","b","c"]] } } - //These might Require ep, only add if they are found: + //These might Require ep, only add if they are found: #NOTE this function does not check subfolders like obj. Checking for individual textures is not possible. if (Engine.FindFileInPath("resname_base","EP.crf",string()) || Engine.FindFileInPath("resname_base","ep2.crf",string())){ // gDTexTable.Pictures.extend() } diff --git a/DSConfigDefault.nut b/DSConfigDefault.nut index 313be15..4fb6f6a 100644 --- a/DSConfigDefault.nut +++ b/DSConfigDefault.nut @@ -115,115 +115,10 @@ enum eDLoad } # |-- Auto Texture Replacement --| -/* DAutoTxtRepl automatically sets the Shape->TxtRepl fields out of a predefined set. - This set is constructed from two sources, the .csv files specified below, and from the gDModTable and gDTexTable directly in this file. - Using this file may be more efficient, but managing spreadsheet CSVs is definitely easier and should not cost more than 10ms, so don't think to about it. - - In eDAutoTxtRepl you can specify an unlimited amount of files. - If a category is present in more than one file the models/textures will be combined. So try to avoid doubles. - Prefixing the _variable = "Filename" with a _ will fully replace / overwrite a category that has already been present. - NOTE: TexTables with nested info like DBookWithSide will always be overwritten! - - The DAutoTxtRepl is mainly designed to be used for mission designing, if you want to ship it with your mission I recommend that you use one of these methods inside your mission DSConfiGYourFM.nut file. - getconsttable().eDAutoTxtRepl.somename <- "YourFile.csv" // To add multiple categories - gDModTable.NewCategory <- [values] // Add a nonexistent category - gDModTable.ExistingCategory.extend([values]) // Add values to a already present category - - There are some other ways to add, delete or only add a texture if it is present. See the documentation for these. -*/ - -enum eDAutoTxtRepl -{ - kFile = "DAutoTxtRepl.csv" // Filename which holds the model to texture references. - //_kAltFile = "Overwrite.csv" // A second file, which could be more mission specific. Enable this in your custom config. - // anotherfile = "something.notacsv" - - // ---------------------------------------------------- - kSeparator = ';' // By which separator are the cells separated after export. Use '\t' for tab. - // , is internally used as a separator as well, this option allows you to add another separator for better division inside the spreadsheet files. - - // 0 or false: Editor only; script will be completely absent in the game.exe and use less memory. - // 1 Once in the editor + once at mission start, to add a little variation. - # Here the script will only be compiled during the mission start. After a save game load it is absent. - // 2 Will be done after each reload, in game and in editor. - # With the DAutoTextReplLock parameter you can disable this manually on the objects. - # This option is the only method which allows the AutoTxtRepl to be used on newly created objects during game time after a save game load. - kUseInGame = false -} - -// This is a minimal tables, based mostly on models provided by me and some others I found usefull to add, -if (IsEditor() || eDAutoTxtRepl.kUseInGame){ - -gDModTable <- - { // Standard is TexRep0, insert a number (1-3) behind a model name to change it to TexRep# - // a $ will be replaced by the entries in an array in the slot directly behind the model name: "Model$",["A","B"] -> "ModelA","ModelB" - // a # will be replaced by the numbers x->y specified a slot behind the model name "Model_#",1.3 -> Model_1,Model_2,Model_3 - // All three optional variants can be combined but must! be in the order Array,Float,integer - // "#$Worstcase",["a","b"],1.2,3 -> a1Worstcase,3,a2Worstcase,3,b1Worstcase,3,b2Worstcase,3 - - Pictures = ["NVPictureFrame","QuaintMain","DL_Wpaint1","res_pntv","res_pnth"] - Bushes = ["DBushR","DBushR#",2.5] - Branches = ["5aLeaves","7aLeaves"] - Barks = ["#$Tree",["a","b"],1.4,"DTrunk#$",["a","b"],1.4,"#$Trunk",["a","b"],1.2,"1aTrunklod1","1aTrunklod2","1aTrunkN","1bTrunkN","1bTrunklod1","1bTrunklod2","2aBushTrunk","2aTrunkLod1","2aTrunkLod2","2bTrunklod1","2bTrunklod2","3aTreelod1","3aTreelod2","3aTrunk","3aTrunklod1","3aTrunklod2","3bTreelod2","3bTrunkHP","3bTrunklod1","3bTrunklod2","3bTrunkLP","3cTree","3cTrunklod2","3cTrunklod1","3dTree","3dTrunk","3dTrunklod1","3dTrunklod2","4aTrunk","4aTrunklod2","DL_tabletrunk","TreeBoughtFarm","TreeTorB_01"] - DLeaves = ["#$Tree",["a","b"],1.4,1,"DLeaves#$",["a","b"],1.4,"#$Leaves",["a","b"],1.4,"DLeaves3c","DLeaves3d","1aLeaveslod2","1bLeaveslod1","1bLeaveslod2","1bLeaveslod3","2bLeavesLod2","3aLeaveslod1","3aLeaveslod2","3aTreelod1",1,"3aTreelod2",1,"3bLeaveslod1","3bLeaveslod2","3bTreelod2",1,"3cTree",1,"3dTree",1,"4bLeavesLod2"], - Book31 = ["DBook","DBookB"] //Bind Cover ratio id 3:1 - DBookWithSide = ["DBook2","DBookB2"] - Banner = [] - Windows = ["DWin1","DWin3","House07Win1","House07Win2"] - //If the Textures are in a SubTable the behind number indicates the max field that should be filled. So for example BookWithSide=[..,"MyBook(.bin)",1,..] would only get TexRepr0 and TexRepr1 - Buildings4R = ["House05RTower",0,"House01R","House02R","House03R","House04R","House05R","House05Rb","House05RSingle","House06R"] - // Roof = [] - CoSaSBeds = ["jbg-nubed0#",1.9,"jbg-nubed10"] - } - -gDTexTable <- - { - //A number behind a Texture will replace the # with 1->i for integer and for example 18->32 for float numbers. - //While it doesn't matter if a user doesn't have the models in ModTable you should make sure that the ones here in TexTable are provided or standard. - Pictures = ["Paint1","Paint#",18.32, "RipOff",11,"RipOff",13.16] // RipOff12 is landscape format. - Bushes = ["PlantDa_#",20] - Branches = ["falbrch","leaves#",4,"branch#",8,"v_Abranch","v_Abranch2","v_asp","v_branch","v_bush","v_fir","v_mapleaf","v_mapleaf2","smtrbrch","sprbrch","vindec3"] - Barks = ["bark#256",6,"v_apbark","v_obark","v_mapbark","v_seqbark","v_vbrk","GBark"] - DLeaves = ["leaves#",4], - Book31= - { - //Here these should be strings and a : instead of = - "0":["Book#",14], - "2":["DPage2Text"], - "3":["DBindBlack","DBindBlue","DBindGreen","DBindRed","DBindYel"], - } - DBookWithSide = - { - //string with #field names which should share the same randomed index both arrays must have the same size. - KeepIndex = "01", - "0":["Book#-0",13], - "1":["Book#-1",13], - "2":["DPage2Text"], - "3":["DBindBlack","DBindBlue","DBindGreen","DBindRed","DBindYel"], - } - Banner = ["banner#",6,"banner8","banstar",3,"NVBanStar01"] - // Doors = [] - Buildings4R = - { - "0":["fam\\HQCity\\DCWall#",24] - "1":["fam\\HQCity\\DCWall#",24] - "2":["fam\\HQCity\\DCWall#",24] - "3":["fam\\HQCity\\DCWall#",24] - } - Roof = ["roof","rooftile"], - CoSaSBeds = - { - KeepIndex="013", - "0":["jbg-bedsd-$",["i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"],"jbg-bedsp02","jbg-bedsp0#",4.9,"jbg-bedsp#",10.12,"jbg-bedsp-$",["b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"]] - "1":["jbg-bedra-$",["i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"],"jbg-bedra02","jbg-bedra0#",4.9,"jbg-bedra#",10.12,"jbg-bedra-$",["b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"]] - "2":["jbg-pillow-$",["c","g","d","b","b","g","d","d","b","h","c","g","b","d","b","h","b","e","b","d","h","b","c","c","e","b","c","h"],"jbg-pillow-$",["b","c","d","e","f","g","h","h","g","g","e","b","c","f","f","b","c","b","e","e","g","e","h","b","c"]] - } - } - //These might Require ep, only add if they are found: #NOTE this function does not check subfolders like obj. Checking for individual textures is not possible. - if (Engine.FindFileInPath("resname_base","EP.crf",string()) || Engine.FindFileInPath("resname_base","ep2.crf",string())){ - // gDTexTable.Pictures.extend() - } -} +/* Moved out of this file: the DAutoTxtRepl configuration (enum eDAutoTxtRepl, gDModTable, + gDTexTable) now lives in its own file, DSConfigDefAutoTxt.nut. It used to be declared here + as well, so eDAutoTxtRepl ended up in the const table twice. + Override it from your own DSConfigMyFM.nut, see the header of DSConfigDefAutoTxt.nut. */ // ------------------------------------------------------------------------------------- // ------------------------------------------------------------------------------------- diff --git a/DSConfigMyFM.nut b/DSConfigMyFM.nut index 3165a30..0170352 100644 --- a/DSConfigMyFM.nut +++ b/DSConfigMyFM.nut @@ -1 +1,5 @@ -const kReplyMessage = "This works" \ No newline at end of file +// Per-FM overrides. This file is loaded after DSConfigDefault.nut and DSConfigFix.nut, +// so anything declared here wins. Ship it with your mission, see DSConfigMyFM Example.nut. +// +// Override a constant of one of the earlier layers by declaring it again, for example: +// const kReplyMessage = "This works" From 0d5e540ad0eb0e7b34c7c0b34df80d7501da1aba Mon Sep 17 00:00:00 2001 From: Daniel Sperber Date: Wed, 5 Aug 2026 16:10:47 +0000 Subject: [PATCH 6/9] docs: add KNOWN_ISSUES, rewrite README, fix stale comments New: docs/KNOWN_ISSUES.md - the user-facing half of the review findings. An alpha with 141 statically-confirmed defects has to tell mission authors which script classes are known-broken (DHub, DHitScanTrap, persistent save, DTrigQVar, DImUndercover's modes, DRay's second activation, the Copies breakage, the SS2 gaps) so they don't spend a day debugging their Design Note for our bug. README: rewrote for the alpha. It still advertised "v1.0 is coming" and the scripts-in-progress branch. Now: what V2 is, the pre-alpha caveat with a link to KNOWN_ISSUES, the intended shipped file set (including why DT2UndercoverWeapons.nut stays - its own header makes it the opt-in companion of DImUndercover, not legacy), the config-layer load order, the DromEd commands and the Notepad++ language file. Comment corrections, no code touched: - Core.nut:3 named a nonexistent #include (DConfigDefault.nut) and referenced the deleted DScript.nut monolith. - Core.nut DBaseTrap Help2 advertised DBaseTrapBlockMessage=, which does not exist. The real parameter is ExclusiveMessage. - General.nut DoOn carried a pasted pre-API-11 ObjRaycast signature (BOOL bSkipMesh) while the code below depends on the API 11 int flags meaning - RenderedOnly + IgnoreAI are summed into that slot. Noted explicitly so nobody "fixes" the code against the comment. - General.nut DHitScanTrap docstring now documents the ignore_set parameter and why its odd spelling cannot be renamed. - General.nut StackToQVar: the comment claimed Create uses the script object directly, which is exactly what the if below does not do. - File&Blob.nut _typeof: loud note that typeof reports the wrapped stream type on purpose and instanceof is the only reliable test. - SFX.nut DHudCompass: put the docstring's */ and the # banner on separate lines like the rest of the file. - CLAUDE.md: the engine reference lives in DOC/squirrel_script/, not docs/squirrel_script/. Also tracks the alpha cleanup docs themselves (the plan, the two review waves and OPEN_TASKS, which were untracked) and flips the OPEN_TASKS rows this pass closed: T-02, T-03, T-05, T-60, T-61, T-62, T-63, T-86 done; T-04 and T-67 partially, with what is left noted in the row. Refs T-04, T-67, T-86, T-87. --- CLAUDE.md | 4 +- DScript Core.nut | 6 +- DScript File&Blob.nut | 4 + DScript General.nut | 18 +- DScript SFX.nut | 4 +- README.md | 99 +++++++- docs/ALPHA_CLEANUP_PLAN.md | 188 +++++++++++++++ docs/KNOWN_ISSUES.md | 88 +++++++ docs/OPEN_TASKS.md | 200 ++++++++++++++++ docs/review/wave1/SUMMARY.md | 100 ++++++++ docs/review/wave1/dbasetrap.md | 89 +++++++ docs/review/wave1/dbasics.md | 121 ++++++++++ docs/review/wave1/dhub.md | 77 ++++++ docs/review/wave1/drelaytrap-dtrigger.md | 58 +++++ docs/review/wave1/dscript-namespace.md | 101 ++++++++ docs/review/wave1/dscripthandler.md | 88 +++++++ docs/review/wave2/SUMMARY.md | 120 ++++++++++ docs/review/wave2/fileblob-dfile.md | 235 ++++++++++++++++++ docs/review/wave2/fileblob-persistence.md | 235 ++++++++++++++++++ docs/review/wave2/general-buttons-hitscan.md | 168 +++++++++++++ docs/review/wave2/general-utility-traps.md | 82 +++++++ docs/review/wave2/overlays.md | 68 ++++++ docs/review/wave2/qvar-traps.md | 168 +++++++++++++ docs/review/wave2/sfx-hud.md | 73 ++++++ docs/review/wave2/sfx-inventory.md | 155 ++++++++++++ docs/review/wave2/sfx-ray-camera.md | 238 +++++++++++++++++++ docs/review/wave2/sfx-tweq-teleport.md | 63 +++++ 27 files changed, 2830 insertions(+), 20 deletions(-) create mode 100644 docs/ALPHA_CLEANUP_PLAN.md create mode 100644 docs/KNOWN_ISSUES.md create mode 100644 docs/OPEN_TASKS.md create mode 100644 docs/review/wave1/SUMMARY.md create mode 100644 docs/review/wave1/dbasetrap.md create mode 100644 docs/review/wave1/dbasics.md create mode 100644 docs/review/wave1/dhub.md create mode 100644 docs/review/wave1/drelaytrap-dtrigger.md create mode 100644 docs/review/wave1/dscript-namespace.md create mode 100644 docs/review/wave1/dscripthandler.md create mode 100644 docs/review/wave2/SUMMARY.md create mode 100644 docs/review/wave2/fileblob-dfile.md create mode 100644 docs/review/wave2/fileblob-persistence.md create mode 100644 docs/review/wave2/general-buttons-hitscan.md create mode 100644 docs/review/wave2/general-utility-traps.md create mode 100644 docs/review/wave2/overlays.md create mode 100644 docs/review/wave2/qvar-traps.md create mode 100644 docs/review/wave2/sfx-hud.md create mode 100644 docs/review/wave2/sfx-inventory.md create mode 100644 docs/review/wave2/sfx-ray-camera.md create mode 100644 docs/review/wave2/sfx-tweq-teleport.md diff --git a/CLAUDE.md b/CLAUDE.md index fb25a32..7a0fc03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ problem tracked as `T-01` is resolved; `docs/OPEN_TASKS.md` has the details. features are missing from it. Trust the code. - `docs/userDefineLang_Squirrel DScript.xml` — Notepad++ syntax + fold definition. - `backup/`, `obj/`, `strings/` — snapshots and DromEd assets, not build inputs. -- `docs/squirrel_script/` — **`squirrel.osm` engine API docs**, not DScript-specific. This is the +- `DOC/squirrel_script/` — **`squirrel.osm` engine API docs**, not DScript-specific. This is the underlying Dark Engine/Squirrel binding that all of DScript is built on top of; consult it for anything DScript's own docs don't cover, or to check what the engine itself provides vs. what DScript adds: @@ -245,6 +245,8 @@ Errors surface in `monolog.txt` (editor) or `Thief2.log` / `Shock2.log` (game). **Full task list with file:line, cause and suggested fix: [`docs/OPEN_TASKS.md`](docs/OPEN_TASKS.md)** (grouped, ID'd `T-nn`, ordered — start there rather than re-auditing). +The user-facing subset — which script classes a mission author must not rely on — is +[`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md); keep it in sync when a `T-nn` gets fixed. The headline items: diff --git a/DScript Core.nut b/DScript Core.nut index 5ba7671..6f1371f 100644 --- a/DScript Core.nut +++ b/DScript Core.nut @@ -1,7 +1,7 @@ ## --/ HEADER --/ -#include DConfigDefault.nut -// This file IS NECESSARY for DScript.nut to compile. +#include DSConfigDefault.nut +// This file IS NECESSARY for DScript Core.nut to compile. // In it are adjustable constants which you might want to change depending on your need. // Like setting a minimum required version for your Fan Mission. // @@ -1577,7 +1577,7 @@ class DBaseTrap extends DBasics //---------------------------------- //---------------------------------- diff --git a/DScript File&Blob.nut b/DScript File&Blob.nut index 16c40ea..73e01c3 100644 --- a/DScript File&Blob.nut +++ b/DScript File&Blob.nut @@ -171,6 +171,10 @@ myblob = null // As we will work more with the derived dblob class } // |-- Metamethods --| + /* #IMPORTANT: _typeof deliberately reports the type of the WRAPPED stream, not "instance". + So typeof mydfile gives "file" (or "blob" for a dblob) and can NEVER be used to test + whether something is a dfile/dblob/dCSV. Use instanceof for that - it is the only + reliable test: if (x instanceof ::dfile) */ function _typeof() return typeof myblob diff --git a/DScript General.nut b/DScript General.nut index d3502dc..44f9082 100644 --- a/DScript General.nut +++ b/DScript General.nut @@ -119,8 +119,12 @@ The Object that was hit will receive the message specified by DHitScanTrapHitMsg By default when any object is hit a TurnOn will be sent to CD Linked objects. Of course these can be changed via DHitScanTrapTOn and DHitScanTrapTDest. -Alternatively if just a special set of objects should trigger a TurnOn +Alternatively if just a special set of objects should trigger a TurnOn then these can be specified via DHitScanTrapTriggers. + +Objects that the beam should pass through are given as DHitScanTrapignore_set +- note the spelling, this parameter is lowercase with an underscore, unlike every +other parameter. Renaming it would break existing Design Notes, so it stays. */ #################################################################### { @@ -131,14 +135,18 @@ hloc = vector() function DoOn(DN){ /* - int ObjRaycast(vector from, vector to, vector & hit_location, object & hit_object, int ShortCircuit, BOOL bSkipMesh, object ignore1, object ignore2); + int ObjRaycast(vector from, vector to, vector & hit_location, object & hit_object, int ShortCircuit, int flags, object ignore1, object ignore2); // perform a raycast on objects and terrain (expensive, don't use excessively) // 'ShortCircuit' - if 1, the raycast will return immediately upon hitting an object, without determining if there's // any other object hit closer to ray start // if 2, the raycast will return immediately upon hitting any terrain or object (most efficient // when only determining if there is a line of sight or not) # Means if there is a hit something obscures. - // 'bSkipMesh' - if TRUE the raycast will not include mesh objects (ie. characters) in the cast + // 'flags' - if bit 0 is set, the raycast will not include mesh objects (ie. characters) in the cast + // if bit 1 is set, the raycast will only include objects whose Render Type property is + // Normal or Unlit [new flag in T2 v1.27 / SS2 v2.48] + # The code below relies on the new int meaning: RenderedOnly (bit 1, default 2) + # plus IgnoreAI (bit 0) are added up and passed in this slot. // 'ignore1' - is an optional object to exclude from the raycast (useful when casting from the location of // an object to avoid the cast hitting the source object) // 'ignore2' - is an optional object to exclude from the raycast (useful in combination with ignore2 when @@ -390,9 +398,9 @@ DefOn="+Contained+Create+Combine" } function StackToQVar(qvar = false){ - local invObj = self // Create and combine is directly the script object. + local invObj = self // Contained and Combine act directly on the script object. if ( message().message == "Create") - invObj = GetObjOnPlayer(Object.Archetype(self)) // When dropped, get the object in the inventory. If non exist Property.Get will return 0. + invObj = GetObjOnPlayer(Object.Archetype(self)) // Create means a copy was split off, so look up the item that stayed in the inventory. Returns null if there is none. if (qvar && qvar != "") // TODO should qvar exist? create it. Quest.Set(qvar,Property.Get(invObj,"StackCount"),eQuestDataType.kQuestDataMission) diff --git a/DScript SFX.nut b/DScript SFX.nut index 9923dc9..aaa426b 100644 --- a/DScript SFX.nut +++ b/DScript SFX.nut @@ -542,8 +542,8 @@ Similar to DHudCompass attaches the [DHudObject]{Object}; by default the selecte The objects facing will be constant toward the camera. With {Rotation} chose an offset. NOTE: Z-Rotation does not work intuitively as it is in combination with pitch. Use X,Y 180° Rotation to imitate a Z 180° rotation. - -*/####################################### +*/ +####################################### { function GetRotation(){ local v = Camera.GetFacing() diff --git a/README.md b/README.md index 4df6f1f..d62f601 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,96 @@ -# Dark-Squirrel-Scripts short DScript is a Squirrel based code for the games Thief 1&2 and their editor DromEd. +# Dark-Squirrel-Scripts (DScript) -Beside the basic included scripts DScript is now one of the four largest collection of scripts made by the community over the past years. -With the 2017 update of the game to the NewDark Version 1.25 it is now possible to write scripts in the squirrel language simply in a text editor which is later compiled by the game. +A Squirrel scripting framework for **Thief 1/Gold**, **Thief 2** and **System Shock 2**, and for the +DromEd/ShockEd editors. It runs on NewDark's `squirrel.osm` — since the 2017 NewDark 1.25 update +scripts are plain text files compiled by the game at load, no DLL toolchain needed. -____________________________________________________________________________________________ +Besides the scripts it ships, DScript is meant to be used as a **framework**: `DBaseTrap` handles +message routing and the universal parameters (`Count`, `Capacitor`, `Delay`, `Repeat`, `FailChance`, +`Condition`, `ExclusiveMessage`, `Copies`, `Debug`) for nearly every script included, so your own +script only has to implement `DoOn` / `DoOff`. The `DScript` library table adds object-set, +geometry, string and QVar helpers you can call from anywhere. -By design the DScript can be used as a framework for your own scripts. It provides various helpfull functions for example grabbing parameters and especially the DBaseTrap script which generally manages all the messages and universal parameters like repeats or delays for nearly all scipts included. +Requires **NewDark ≥ 1.25** with `GetAPIVersion() >= 11` (Thief 2 v1.27 / SS2 v2.48). -____________________________________________________________________________________________ +## Status: V2 pre-alpha (0.81) -Old: Besides one or two script ideas I'm about to say that the DScript is nearly 'finished'. So I'm open for new ideas and requests. +This branch is a **pre-alpha rewrite** that split the old single-file DScript into layers. It is +*not* a stable release and has only been minimally tested. -New: DScript currently is undergoing a massive improvement and new additions in the scripts-in-progress branch. -v1.0 is coming +**Read [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md) before building a mission on it.** Two static +review passes confirmed 141 defects; several scripts — `DHub`, `DHitScanTrap`, the persistent-save +family, `DTrigQVar`, `DImUndercover`'s modes — do not work at all yet. The full task list is +[`docs/OPEN_TASKS.md`](docs/OPEN_TASKS.md). + +If you need something that works today, use the last v1 release rather than this branch. + +## Installing + +Copy the files into `/sq_scripts/`. The engine compiles **every** `.nut` in that folder in +filename order, and later definitions win — which is why the file names matter. + +| File | Ship it? | Contents | +|---|---|---| +| `DScript Core.nut` | yes | The framework: `DScript` library table, `DBasics`, `DBaseTrap`, `DRelayTrap`, `DTrigger`, `DScriptHandler`, `DHub`, the QVar traps | +| `DScript General.nut` | yes | Gameplay traps: buttons, hitscan, property copying, script adding, the undercover suite | +| `DScript SFX.nut` | yes | Visual/inventory/camera scripts: rays, HUD, inventory masters, director, teleporters | +| `DScript File&Blob.nut` | yes | `dfile`/`dblob`/`dCSV` file reading (backs the `>` operator) and the persistence classes | +| `DScript Overlays.nut` | yes | Overlay handlers: in-game log, per-mid-frame updater, world-inventory overlay | +| `DSConfigDefault.nut` | yes | All tunable constants, separators, QVar storage types, mission constants, the `_dFROM` message patches | +| `DSConfigDefAutoTxt.nut` | if you use `DAutoTxtRepl` | The texture-replacement model/texture tables | +| `DSConfigFix.nut` | yes | Layer for resolving constant conflicts with other authors' scripts | +| `DSConfigMyFM.nut` | yes | Layer for your mission's own overrides | +| `DScript_ModdingTools.nut` | editor only | `DSpy`, `DAutoTxtRepl`, `DDumpModels`, `DEditorTrap`, `DTestTrap`, `DPerformanceTest`. See the note in KNOWN_ISSUES about `DTestTrap` before leaving it out | +| `DT2UndercoverWeapons.nut` | only with `DImUndercover` | Thief 2 only. Replacement weapon scripts that make the player's weapons suspicious. Delete it if you do not use `DImUndercover` | +| `T2OverlaySample.nut` | no | A `squirrel.osm` overlay sample, kept for reference. Also in `DOC/squirrel_script/samples/` | + +`DSConfigFix Example.nut` and `DSConfigMyFM Example.nut` are templates — copy the lines you need +into the real `DSConfig*.nut` files, don't ship the examples. + +## Configuration layering + +Constants are declared in layers, each loaded after the previous one, so a later declaration +overrides an earlier one: + +``` +DSConfigDefAutoTxt.nut texture replacement tables +DSConfigDefault.nut the defaults - read this one to see what can be changed +DSConfigFix.nut fixes for conflicts with other authors' scripts +DSConfigMyFM.nut your mission's overrides +``` + +To change something, do **not** edit `DSConfigDefault.nut` — redeclare the constant in +`DSConfigMyFM.nut`. The load order is filename order, which is why the layers are named the way they +are. + +## Using it in DromEd + +| Command | Purpose | +|---|---| +| `script_load squirrel` | Load the Squirrel module | +| `script_reload` | Recompile all `.nut` files. Also the only way to refresh Count/Capacitor data — put it in `GameMode.cmd` | +| `script_test ` | Fire a script's `OnTest()` handler | +| `set deditor` | Make editor-only scripts announce themselves, so you catch them before shipping | + +Scripts are configured through the object's **Design Note**, as +`[ScriptName][On|Off]=value;…`. Setting `[ScriptName]Debug=1` prints the framework's +numbered decision stages for that object to `monolog.txt` — the fastest way to find out why a trap +did not fire. Errors appear in `monolog.txt` (editor) or `Thief2.log` / `Shock2.log` (game). + +## Documentation + +- [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md) — what is broken in this pre-alpha. Start here. +- [`docs/OPEN_TASKS.md`](docs/OPEN_TASKS.md) — the developer task list, with file:line. +- `docs/DScript Documentation.pdf` — the user manual, but for **v0.28a**. Roughly 40% of the current + features are missing from it, and some described behaviour has changed. Trust the code. +- `docs/userDefineLang_Squirrel DScript.xml` — **Notepad++** user-defined language: syntax + highlighting plus the fold markers this codebase uses (`## /-- … --\`). Import it via + *Language → User Defined Language → Define your language… → Import*. There is a matching set for + plain `squirrel.osm` scripts in `DOC/squirrel_script/Notepad++/`. +- `DOC/squirrel_script/` — NewDark's own `squirrel.osm` API reference (globals, services, messages, + samples). Not DScript-specific; this is the engine layer everything here is built on. The + `Custom-API-reference*.nut` files are hand-improved supersets of the `.txt` originals. + +## License + +See [LICENSE](LICENSE). diff --git a/docs/ALPHA_CLEANUP_PLAN.md b/docs/ALPHA_CLEANUP_PLAN.md new file mode 100644 index 0000000..34652cc --- /dev/null +++ b/docs/ALPHA_CLEANUP_PLAN.md @@ -0,0 +1,188 @@ +# Alpha Release — Cleanup Plan (no bug fixes) + +**Goal:** get the V2 branch into an alpha-releasable state by removing development leftovers — +above all user-visible log spam — **without changing any gameplay behavior**. Bug fixes are +explicitly out of scope; they stay tracked in `docs/OPEN_TASKS.md` and the wave reports +(`docs/review/wave1/`, `docs/review/wave2/`). + +**Sources:** `docs/review/wave1/SUMMARY.md` + per-feature reports, `docs/review/wave2/SUMMARY.md` ++ per-unit reports, `docs/OPEN_TASKS.md` Group F (T-60…T-67) and Group A (T-02…T-05). + +**The one rule for every edit here:** if removing/changing a line could alter what a script *does* +(not what it *prints*), it does not belong in this pass — see the Exclusions section at the end. + +**Handling rules (repo hazards — read before editing):** +- `DScript Core.nut` and `DScript File&Blob.nut` are ISO-8859-1 with mixed line endings. + **Edit with exact byte-matched replacements only. Never re-save, re-encode, or reformat whole + files.** `grep` needs `-a` on both. +- The `## /-- §# … --\` banners are Notepad++ fold markers, not decoration — leave them. +- After each batch: `script_reload` in DromEd, then play a minute and check `monolog.txt` is + quiet. Nothing can be verified outside DromEd. + +--- + +## Batch 1 — User-facing log output (the release blocker) + +Players with `kUseIngameLog = true` see the log tail **on screen**; everyone else gets a spammed +`monolog.txt`/`Thief2.log`. Two treatments: + +- **DELETE** — pure development traces with no diagnostic value. +- **CONVERT** — genuinely useful warnings: route through `DPrint(...)` (Debug-gated) or + `DPrint(msg, kDoPrint, ePrintTo.kMonolog)` per the file's own convention, so they surface only + when an author opts in. + +### 1a. `DScript Core.nut` (grep -a) + +| Line | Anchor / content | Action | Tracked | +|---|---|---|---| +| 1222, 1242, 1289, 1294, 1426 | unconditional `print()` inside `DCheckString` — hottest function in the framework | DELETE | T-60 | +| 1286–1295 | `"yes in "` / `"nope try again"` prints around `Engine.FindFileInPath` (`>` operator) | DELETE the prints only — do **not** touch the unvalidated-path logic (that is T-67's bug half) | T-67 | +| 1596–1597 | `print("yohoho")` behind the never-true class-vs-string compare | DELETE both lines (comparison exists only for the print) | T-61 | +| 1833 | bare `print()` "_script NOT SET" safety warning | CONVERT to the DPrint/ePrintTo convention | wave1 dbasetrap | +| 2218, 2333 | stray `print()` / `print("MissionInitialized")` | DELETE | T-62, wave1 dscripthandler | +| 828 | `DPrint("\nINFO: Saving '" + name …)` in `SetQVar` — ungated, QVar hot path | CONVERT to Debug-gated (or DELETE); string is built on every QVar write | wave1 dscript-namespace | +| 2517 | `print(Object.Exists("DScriptHandler"))` in OnDelete, unlabeled | DELETE | wave1 dscripthandler | +| 2770 | `::print("DID BEGIN")` — whole OnBeginScript override exists only for this; delete the override | DELETE | T-62 | +| 2805 | `::print("DID SIM")` | DELETE | T-62 | +| 2864 | `print(type(trigger) + typeof vars)` — runs per trigger on every QVar change | DELETE | T-62 | +| 2775, 2777, 2779, 2788 | four unconditional prints in `InitQVarFromProp` (one has an unbalanced quote) | DELETE | wave2 qvar-traps | +| 2823 | `print("Saving QVar Trigger" + instance)` | DELETE | wave2 qvar-traps | +| 2924 | `print("MODE CHANGED")` | DELETE | wave2 qvar-traps | + +### 1b. `DScript SFX.nut` + +| Line | Anchor / content | Action | Tracked | +|---|---|---|---| +| 1443, 1477, 1516, 1521, 1544, 1651, 1659 | DDirector dev prints (`print("Speed is" + speed)`, `::print("data is " + data)`, `print("speec" + speed)`, …) | DELETE | T-63 | +| 869 | `print("Hi I'm a " + …)` in `DUseInventoryMaster.OnContained` | CONVERT — fold the message into the `DPrint("")` that already gates it | wave2 sfx-inventory | + +### 1c. `DScript File&Blob.nut` (grep -a) + +| Line | Anchor / content | Action | Tracked | +|---|---|---|---| +| 459 | `print("separator is " + separator.tochar())` on every dCSV construction | DELETE | wave2 fileblob-dfile | +| 587 | `print(IsOn)` | DELETE | wave2 fileblob-persistence | +| 684–685 | `print("map :" + map)` / `print("name :" + name)` | DELETE | " | +| 741, 750 | `print("Found no slot, …")` / `print("mission save not in 63, …")` | CONVERT (legit diagnostics) or DELETE | " | +| 781, 794, 796 | `print(slot+data)` / `print(MissData)` ×2 — dump raw save data every call | DELETE | " | +| 854 | `print(typeof event_data)` | DELETE | " | + +### 1d. `DScript_ModdingTools.nut` + +| Line | Anchor / content | Action | Tracked | +|---|---|---|---| +| (several) | `DPerformanceTest` dev prints | DELETE/CONVERT — locate with `grep -n "print(" DScript_ModdingTools.nut`; this file was not covered by wave 2 (skipped), so sweep it mechanically for prints only | T-63 | + +**Acceptance for Batch 1:** start a test mission with several DScript objects, play 2 minutes, +open `monolog.txt` — zero DScript output unless a Design Note sets `Debug=1`. + +--- + +## Batch 2 — Packaging & config duplication (release-critical hygiene) + +| Item | What | Action | Tracked | +|---|---|---|---| +| `DSConfigDefAutoTxt.nut` vs `DSConfigDefault.nut:117–231` | `enum eDAutoTxtRepl`, `gDModTable`, `gDTexTable` declared verbatim twice; duplicate enum may be a hard compile error | Strip the AutoTxt block out of `DSConfigDefault.nut`, keep the dedicated file (OPEN_TASKS' own recommendation). **Only cleanup item in this plan that changes what gets compiled — verify with `script_reload` immediately after** | T-02 | +| `DSConfigFix.nut:6` / `DSConfigMyFM.nut:1` | `const kReplyMessage` declared in both layers | Keep one (Fix layer); in the other, demonstrate override syntax in a comment, following the `* Example.nut` files | T-03 | +| `.gitignore` | `backup\` / `obj\` use backslashes — directories not actually ignored | `backup/`, `obj/` | T-05 | +| Repo root | `DT2UndercoverWeapons.nut` is legacy, unrelated to V2 | Move to a `legacy/` folder (or document why it ships) — decision left over from T-01 | T-01 residue | +| Release zip contents | Decide whether `DScript_ModdingTools.nut` ships — it is editor-only tooling; wave 1/2 confirmed shipped-game references to `DTestTrap` crash (that *dependency* is a bug, out of scope here — but the packaging decision is not) | Document the intended file set for the alpha in the README | — | + +--- + +## Batch 3 — Dead code with no open design question (safe deletions) + +Only commented-out or provably-unreferenced code that is **not** tied to an open T-nn decision. + +`DScript Core.nut` (grep -a): +- :616 — commented-out "safer method to get self" alternative in `_tempstore._get` +- :993 — commented-out alternate `_get()` delegate ("performance wise nahh") +- :1598 — commented-out debug print in the DBaseTrap constructor +- :2249–2255 — dead `CallbackExtern` branch ("Currently not used" per its own comment) incl. its two stray prints +- :2260–2267 — commented-out `_get(key)` metamethod superseded by the Extern-delegate mechanism +- :2343 — `ReRegisterWithKey` **after verifying** zero call sites (`grep -a` first); the live path is the inline logic at 1652–1665 +- :2632 — `// DumpTable(userparams())` in the DHub constructor +- :2724 — commented-out DoOn stub that only calls base +- :2935, :2950 — commented-out `::DHandler.Extern.DQVarHandler.` prefixes above the live calls + +`DScript General.nut`: +- :336 — `// print("Done" + obj)` + +`DScript SFX.nut`: +- :276 — commented `removeViewer` member (its live fix is bug work; the comment line itself is dead) +- :291–296 — `#DEBUG POINT` block in `PanToTarget` +- :766 — commented `//::Property.Set(CreateHolder(),"Scripts","Script 1", "DSpy")` +- :1634–1636 — commented Link.Destroy/Create/DoOn triple in DDirector.DoOff + +`DScript File&Blob.nut` (grep -a): +- :84–89 — commented `getParamOld` +- :285 — the no-op `str.tointeger()` statement in dblob's float case (statement is dead; the fall-through behavior stays exactly as it is) + +`DScript Overlays.nut`: +- :108 — `// Engine.GetCanvasSize(W,H)` referencing undeclared locals +- :128, :164, :168–169 — commented experimental overlay calls (screen bounds, text color, underline) + +**Keep (do NOT delete — tied to open decisions):** Core:1025 (commented empty-string guard = the +candidate fix for a wave-1 bug), Core:891 (T-64 — the unreachable line may be the *correct* one), +Core:2018–2019 (T-65/T-70 TODO), SFX:113–119 (DRay attach block, T-73), SFX:794–808 (DSubInventory +discontinued block, T-75 — author's record of a rejected design), Overlays:87 (commented +`UpdateTOverlaySize` — part of the open T-52 question), File&Blob:802 (`HexCharToInt` — one of the +two mechanisms T-77 must choose between). + +--- + +## Batch 4 — Comment & documentation corrections (zero code risk) + +- `CLAUDE.md` — the engine-reference path is wrong: `docs/squirrel_script/` → `DOC/squirrel_script/` (every mention). +- `README` — still advertises "v1.0 is coming" and the old branch; rewrite minimally for the alpha: what V2 is, pre-alpha caveat, config-file layering, Notepad++ language file (T-86). +- **New: `docs/KNOWN_ISSUES.md`** — an alpha with 141 statically-confirmed bugs (55 wave 1 + 86 wave 2) must say so. Generate a short user-facing list from the two SUMMARYs: which script classes are known-broken (`DHub`, `DHitScanTrap`, `DImUndercover` modes, persistence, `DTrigQVar`, `DRay` re-trigger…) so mission authors don't burn days on known problems. This is the cheapest, highest-value release artifact in this plan. +- `DScript Core.nut:1584` — Help2 metadata names a parameter (`DBaseTrapBlockMessage=`) that does not exist; the real one is `ExclusiveMessage`. +- `DScript Core.nut:3` — `#include DConfigDefault.nut` comment vs actual filename `DSConfigDefault.nut` (T-04; settle the naming in the comment only — renaming files is packaging, not this pass). +- `DScript General.nut:134–148` — stale pasted `ObjRaycast` API comment (describes `BOOL bSkipMesh`; API 11 defines `int flags`, and the code depends on the new meaning). Fix the comment so nobody "fixes" the code against it. +- `DScript General.nut:393` — misleading comment about which messages use `invObj = self`; reword to match the code (do not touch the code). +- `DScript File&Blob.nut:174` — add a loud comment at `_typeof()` explaining that `typeof` deliberately reports the wrapped type and `instanceof` is the only reliable test. +- `DScript SFX.nut:546` — cosmetic: put DHudCompass's `*/` and the `#` fold banner on separate lines, matching the file's own convention. +- Document `DHitScanTrap`'s `ignore_set` parameter spelling in its docstring (renaming the parameter would break existing Design Notes — document, don't rename). + +--- + +## Batch 5 — Naming/style unification (optional, do last) + +Low value individually; do only if time remains, one commit, no logic edits: + +- `DScript File&Blob.nut:591` — `callee()` → `::callee()` (keeps the T-30/T-31 defect class greppable). +- `DScript Core.nut:2967` — rename `local type` (shadows global `type()`). +- `DScript General.nut:357` — `DRemoveSciptFunc` typo: rename **only after** `grep -arn "DRemoveSciptFunc"` confirms all call sites are internal to the file, and update them in the same edit. +- `DScript SFX.nut:1097` vs :1119–1121 — use the named constants (`kGetFirstChar`/`kRemoveFirstChar`) in the constructor as `DoOn` already does. +- `DScript General.nut:64–65` — `FALSE` vs `true` two lines apart; pick the Squirrel literals. + +--- + +## Explicitly EXCLUDED — cleanup-shaped items that are really bug fixes + +Listed so nobody "just quickly" does them in this pass. Each changes behavior; all stay in +`docs/OPEN_TASKS.md` / wave reports for the bug-fix wave: + +- T-64 (Core:891) and T-65 (Core:2018) — the "dead" lines encode unresolved intent. +- T-66 (SFX:1603 `if (true || …)`) — removing either side changes DDirector's timing path. +- Core:1025 — restoring the commented empty-string guard is the fix for a confirmed wave-1 bug. +- Any `find(...)` truthiness change (`if (x)` → `if (x != null)`) — T-23 class, behavior change. +- `GetClassName()` → `_script` parameter reads (DDirector Freelook, DHudObject Rotation/Spin, DDrunkPlayerTrap, DRay DoOff) — fixes `Copies` behavior. +- dfile constructor rethrow, `cDCustomHandler.GetClassName` throw path, `Object.Destroy(null)` guard, DInventoryMaster timer-arming guard, `hobj`/`hloc` per-instance nulling — all defensive-behavior changes. +- Overlays :32–46/:60–75 duplicated position block — factor it together **with** the T-52 fix in the bug wave, so the fix lands once; refactoring it now would double-touch an ISO-hazard-adjacent file for no user benefit. +- `GetSaveRaw`'s unreachable else (File&Blob:737), buttons' duplicated On/Off result blocks (:191–206), tweq's duplicated Joints parsing (:1078), DBasics' near-duplicated QVar substitution (Core:1148) — all sit inside confirmed-buggy functions; touch them when fixing those functions. +- `dhelp` stub / hello banner (T-71) — filling them is feature work, not cleanup. + +--- + +## Suggested execution order & commits + +1. `cleanup: strip debug prints (Core)` — Batch 1a +2. `cleanup: strip debug prints (SFX, File&Blob, ModdingTools)` — Batch 1b–1d +3. `chore: dedupe config layers, fix .gitignore` — Batch 2 +4. `cleanup: remove dead commented-out code` — Batch 3 +5. `docs: fix stale comments, README, add KNOWN_ISSUES` — Batch 4 +6. (optional) `style: naming unification` — Batch 5 + +One `script_reload` + monolog check after every commit; commits 1–2 are the ones a player will +notice, 3–5 are for maintainers. Rough size: ~45 print sites, ~20 dead-code sites, ~10 doc edits. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md new file mode 100644 index 0000000..c40ede4 --- /dev/null +++ b/docs/KNOWN_ISSUES.md @@ -0,0 +1,88 @@ +# DScript V2 — Known Issues (pre-alpha 0.81) + +**Read this before you build a mission on V2.** + +V2 is a pre-alpha rewrite. Two static review passes over the framework (2026-08-02…05) confirmed +**141 defects** — 55 in the base classes, 86 in the individual scripts. Nothing in this branch has +been verified at runtime; the reviews were done by reading the code, not by playing missions. + +This file lists the parts that are known **not to work**, so you don't spend a day debugging your +Design Note for a bug that is ours. It is a summary — the full findings live in +`docs/review/wave1/` and `docs/review/wave2/`, and the fix list with file:line is +[`OPEN_TASKS.md`](OPEN_TASKS.md). + +If your feature is not listed here, that does not mean it works. It means nobody has looked at it +yet, or the review found smaller problems than the ones below. + +## Rule of thumb for the alpha + +- Treat **TurnOff / cleanup paths** as unverified everywhere. They are systematically worse than the + TurnOn paths: effects leak, countdowns survive, timers restart, `OnEndScript` overrides drop + framework registrations. +- Avoid the **`Copies` parameter** on the scripts listed under *Copies is broken* below. +- Avoid **QVar writes** as the backbone of a mission for now (see below). +- Keep an eye on `monolog.txt` / `Thief2.log`. Set `[ScriptName]Debug=1` in a Design Note to see the + framework's own stage-by-stage trace for that object. + +## Completely non-functional + +| Script / feature | What happens | +|---|---| +| `DHub` | Non-functional, as the file header itself says. Undefined variables in its message loop. Don't use it. | +| `DHitScanTrap` | Dead in both directions: `DoOff()` has the wrong signature so every TurnOff throws, and TurnOn throws unless *both* `TOnResult` and `TOffResult` are set. A vector `From` never reaches the raycast. | +| Persistent save (`DPersistentSave`, `DPersistentSaveSimple`, `DPersistentSaveTrap`) | Cannot run. Four independent fatal faults in the read/parse/relay chain, each sufficient on its own. Do not build campaign-persistent state on it. | +| `DTrigQVar` | Never subscribes to any QVar, so it never triggers. When it is made to subscribe, a second bug recurses unboundedly on every QVar change. | +| `DImUndercover` modes | `DImUndercoverMode` has no effect — a bitwise `\|` where `&` was meant means every mode always applies. Custom metaproperties are applied only to already-alerted AIs, the opposite of the intent. | +| `/` ping-back chain operator | Dies on a typo whenever the message carries data. | +| `DTeleportPlayerTrap`, `DTPBase` offsets, `DPortal` ScriptParams destination | Inverted conditions / mis-assigned locals. `DTpY` and `DTpZ` are dead; the ScriptParams fallback never runs. | +| `DRenameItem.OnCreate`, `DStackToQVar`'s QVar write, `DNotSuspAI.OnDamage` | Each throws on an undefined name or a call on a non-function. | +| `set dhelp` | Empty stub. So is the hello banner (it is gated behind a version higher than this one). | +| `DRayAttach`, `DArmAttachmentUseObject` modes 2 and 3 | Documented but not implemented / labelled experimental by the author. | +| `DSubInventory` auto-remove-when-empty | Implemented, then deliberately discontinued. | + +## Works, but wrong + +| Script / feature | What to expect | +|---|---| +| `DRay` | Crashes on the **second** TurnOn in the default configuration, and never destroys the particle object it created on TurnOff. Its particle-count scaling is a no-op. | +| Design Note parsing | Several operators are misparsed: `]objs]links` indexes the wrong parts, `==` in a `Condition` never matches, `& Read [`../CLAUDE.md`](../CLAUDE.md) first — it has the file map, the class hierarchy, and the +> repo gotchas (`grep -a`, mixed encodings, load order). This file is only the task list. + +## Status legend + +| Mark | Meaning | +|---|---| +| ☐ | Open, not started | +| ◐ | In progress | +| ☑ | Done — move the row to the bottom of its table and note the commit | +| ⊘ | Won't fix / accepted — say why | + +**Priority:** **P0** blocks all testing · **P1** crash or feature is dead · **P2** wrong behaviour, +feature still usable · **P3** polish, cleanup, docs + +## Head-start for a new session + +1. `git log --oneline -3` — confirm nothing landed since the snapshot above; if it did, re-verify + line numbers before trusting them. +2. Line numbers here are from the snapshot. Confirm with `grep -an "" "DScript Core.nut"` + (the `-a` is mandatory — see CLAUDE.md). +3. **Nothing can be run, built or tested in this repo.** Fixes are reviewed by reading; behavioural + verification happens in DromEd via `script_reload`. Never report a change as "tested". +4. Work groups top-down. T-01 (legacy files shadowing V2) is **resolved** as of the 2026-08-04 + merge — start with Group B instead. + +--- + +## Group A — Packaging & repo hygiene (P0) + +Blocks everything else. + +| ID | Priority | Location | Problem | Suggested fix | Status | +|---|---|---|---|---|---| +| T-02 | P1 | `DSConfigDefAutoTxt.nut` vs `DSConfigDefault.nut:117-231` | `enum eDAutoTxtRepl`, `gDModTable`, `gDTexTable` declared verbatim in both. Duplicate `enum` may be a hard compile error; duplicate `<-` silently overwrites. *(Line range shifted from 119-228 by the 2026-08-04 merge; `DSConfigDefAutoTxt.nut` itself is untouched, block content unchanged.)* | Keep one. Recommend: strip the AutoTxt block out of `DSConfigDefault.nut`, keep the dedicated file | ☑ done `2ca17b2` | +| T-03 | P2 | `DSConfigFix.nut:6`, `DSConfigMyFM.nut:1` | `const kReplyMessage` declared in both files — still true, both files unchanged by the 2026-08-04 merge. That merge did add sibling `DSConfigFix Example.nut` / `DSConfigMyFM Example.nut` files that gesture at a fix (the Fix example shows overriding via a *different*, commented-out constant name; the MyFM example uses `kOverwriteConstant` instead of colliding on `kReplyMessage`), but the two real config files were not updated to match their own examples | Decide which layer owns it; the other should demonstrate override syntax in a comment — the new Example files are a template for this, not yet applied | ☑ done `2ca17b2` — Fix layer owns it, MyFM shows the syntax in a comment | +| T-04 | P3 | `DScript Core.nut:3` | `#include DConfigDefault.nut` — file is actually `DSConfigDefault.nut`. The comment block in that file proposes `DConfigDefault`/`DConfigMod`/`DConfigThisFM` while reality is `DSConfig*` | Settle the naming scheme, update both | ◐ the comment in `Core.nut:3` now names the real file; the `DConfig*` vs `DSConfig*` scheme itself is still undecided — renaming files is packaging, not a comment fix | +| T-05 | P3 | `.gitignore` | `backup\` and `obj\` use backslashes; git does not treat `\` as a separator, so neither directory is actually ignored | Change to `backup/` and `obj/` | ☑ done `2ca17b2` (both dirs are already tracked, so this only affects new files) | +| T-01 | P0 | repo root | ~~`DScript.nut` (v0.42a monolith), `DSEditorScripts.nut`, `DT2UndercoverWeapons.nut` redefine **~29 V2 class names**~~ — **resolved 2026-08-04.** The upstream merge (commit `155c111`) deleted `DScript.nut` and `DSEditorScripts.nut` outright. A repo-wide scan of every root `.nut` file's top-level `class` names after the merge found **zero duplicates**. `DT2UndercoverWeapons.nut` is still present but only ever defined `BlackJack`/`Sword`/`Arrow` — it never collided with a V2 class name, so it was miscounted in the original "~29" figure | None needed for the shadowing bug. `DT2UndercoverWeapons.nut` **stays** in the root: its own header says it is the companion file for `DImUndercover` ("INCLUDE it in your map if you do"), so it is opt-in, not legacy. Documented as such in the README file-set table instead of being moved | ☑ | + +--- + +## Group B — Undefined variables (P1, throws at runtime) + +Each is a one-word edit. Every row here kills a feature outright. + +| ID | Priority | Location | Problem | Fix | Status | +|---|---|---|---|---|---| +| T-10 | P1 | `DScript Core.nut:1707,1709` | `local inter = …` then `if (!intern)` / `Reply(intern)`. Kills `OnDPingBack` whenever `msg.data` is set — i.e. the entire `/` chain operator | `intern` → `inter` | ☐ | +| T-11 | P1 | `DScript Core.nut:1344` | `^%anchor%name` branch: locals are `str2`, code reads `div_str[1]` (out of scope) | `str = str2[2]` — check the intended split semantics | ☐ | +| T-12 | P1 | `DScript Core.nut:1473` | Same bug in the `->%anchor%Prop:Field` branch | as above | ☐ | +| T-13 | P1 | `DScript Core.nut:2645` | `DGetParam(_entry + "Delay")` — loop variable is `entry` | `_entry` → `entry` | ☐ | +| T-14 | P1 | `DScript Core.nut:2639, 2671` | `if (!val && …)` — `DHub.OnBeginScript` / `OnResetCount` iterate `foreach (k, v …)`; `val` undefined | rename loop var or use `v` | ☐ | +| T-15 | P1 | `DScript General.nut:161` | `vrom = from` — typo for `vfrom`, creates a global instead of setting the raycast origin. `DHitScanTrap` with a vector `From` is dead | `vrom` → `vfrom` | ☐ | +| T-16 | P1 | `DScript General.nut:441` | `::PlayerID()` — `PlayerID` is an integer, called as a function. `DNotSuspAI.OnDamage` throws | drop the `()` | ☐ | +| T-17 | P1 | `DScript SFX.nut:1055` | `DRenameItem.OnCreate` references `DN`, not a parameter of `OnCreate` | use `userparams()` | ☐ | +| T-18 | P1 | `DScript SFX.nut:1409` | `::StackToQVar()` — root-table lookup of an instance method | drop the `::` | ☐ | +| T-19 | P1 | `DScript Core.nut:1165-1166` | reads `getconsttable().MissionsConstants`; `DSConfigDefault.nut:72` (was `:71` before the 2026-08-04 merge) defines `MissionConstants`. `$`-operator fallback never resolves. `DScript_ModdingTools.nut:818` (was `:821`) spells it correctly | fix Core to `MissionConstants` | ☐ | + +--- + +## Group C — Parsing & operator logic (P1/P2) + +Bugs in `DCheckString` and friends. These affect *every* script, because all Design Note parameters +flow through here. + +| ID | Priority | Location | Problem | Fix | Status | +|---|---|---|---|---|---| +| T-20 | P1 | `DScript Core.nut:1123-1132` | `]objs]links` operator: `::split(str,"]")` — Squirrel `split` **drops empty tokens**, so parts land at `s[0]`/`s[1]`, but code uses `s[1]`/`s[2]`. Worse, `s[2]` is dereferenced at :1125 *before* the `s.len() != 3` guard | reindex to `s[0]`/`s[1]`; move the length guard above the deref | ☐ | +| T-21 | P1 | `DScript Core.nut:1879` | `if (cond1.len() != cond2.len)` — missing `()`, compares int to closure, always true. `DCheckCondition` with `==` always takes the not-equal path | `cond2.len()` | ☐ | +| T-22 | P2 | `DScript Core.nut:459` | `if ( !objset.len() == cur_idx )` parses as `(!objset.len()) == cur_idx` → true only at `cur_idx == 0`. `&9999 break the fixed width → collisions (`obj 1234`+`"5X"` vs `obj 12345`+`"X"`) | use a separator, e.g. `"%d|%s"` | ☐ | +| T-37 | P3 | `DScript Core.nut:1607` | Known limitation: Count/Capacitor data is initialised in the editor only, so runtime-created objects never get counters | needs a design decision — a lazy init on `BeginScript` with a one-shot lock was sketched but rejected on memory grounds | ☐ | +| T-38 | P3 | `DScript Core.nut:1985` | Author's own TODO: does `ExclusiveDelay` + infinite repeat cancel without restarting? | verify in DromEd | ☐ | +| T-39 | P3 | `DScript Core.nut:2410` | `PerMidFrame_DoUpdates` hard-references `DHudObject.pos_vector`, coupling Core to `DScript SFX.nut`; throws every frame if SFX isn't shipped | move the vector to `DScriptHandler`, or guard | ☐ | + +--- + +## Group E — Individual scripts (P1/P2) + +| ID | Priority | Script | Location | Problem | Fix | Status | +|---|---|---|---|---|---|---| +| T-40 | P1 | `DHub` | `DScript Core.nut:2538+` | Declared non-functional in the file header; confirmed by T-13/T-14 plus `DGetParamRaw` name-mangling that assumes `_script` is always a prefix of `par` | fix T-13/T-14 first, then re-review the whole class | ☐ | +| T-41 | P1 | `DImUndercover` | `DScript General.nut:569, 593, 598, 616, 622, 638, 650-658` | `if (modes | N)` — bitwise OR, non-zero for any `modes`. **Every mode always applies**; `DImUndercoverMode` has no effect | `|` → `&` throughout | ☐ | +| T-42 | P1 | `DImUndercover` | `DScript General.nut:588 / 647` | The `else // Use Custom Metas only` is attached to `if (alertness < 2)`, not to the `UseMetas` check → metas only apply to *already-alerted* AIs, the opposite of intent | re-nest against `DGetParam(_script+"UseMetas")` | ☐ | +| T-43 | P1 | `DTeleportPlayerTrap` | `DScript SFX.nut:1300-1303` | `if (!dest)` branches inverted → computes `Object.Position(victim) + null` when no offset is set | swap the branches | ☐ | +| T-44 | P1 | `DTPBase` | `DScript SFX.nut:1277-1279` | `local x = ("DTpX" in DN)? x = DN.DTpX : 0;` — `y` and `z` also assign to `x`, each reads its own uninitialised local. `DTpY`/`DTpZ` are dead | rewrite the three lines properly | ☐ | +| T-45 | P1 | `DPortal` | `DScript SFX.nut:1377` | `if (dest == false)` but `GetTeleportVector()` returns `null` → the ScriptParams-destination fallback never runs | `if (dest == null)`, or return `false` consistently | ☐ | +| T-46 | P2 | `DDrunkPlayerTrap` | `DScript SFX.nut:1205` | Re-serialises the timer payload in the wrong order: writes `(…, Length, Length, FadeInTime, …)` into slots read as `(…, Length, FadeInTime, FadeOutTime, …)`. After tick 1 the fade values are corrupt. `:1212` also reads `Length` where `FadeInTime` is meant | rewrite using the `eDrunkData` enum for both read and write | ☐ | +| T-47 | P2 | `DAddScript` | `DScript General.nut:331` | Slot check accepts the slot if the archetype has *any* `Script 3`, rather than checking it matches `newscript` | compare against `newscript` | ☐ | +| T-48 | P2 | `DStackToQVar` | `DScript General.nut:404` | Hard-codes `"DStackToQVarVar"` instead of `_script + "Var"` → breaks under `Copies` and in subclass `DModelByCount` | use `_script` | ☐ | +| T-49 | P2 | `DObjectPanTo` (renamed from `DFocusOverTime` by the 2026-08-04 merge; same class) | `DScript SFX.nut:276, 308, 362` | Author's acknowledged `#BUG`: removing a viewer inside its own `foreach` skips an element, and desyncs the parallel `offset` array | collect removals in a second array, apply after the loop | ☐ | +| T-50 | P2 | `DRay` | `DScript SFX.nut:74` | Property field name `" Max time"` has a leading space (`"Min time"` does not) | verify against the real property name in DromEd | ☐ | +| T-51 | P3 | `DRay` | `DScript SFX.nut:99-103` | Author's "important TODO": particle-count scaling maths is self-cancelling (`extra + d - extra = d`, so `d/n == 1`). Needs old-vs-new value comparison | per the inline note | ☐ | +| T-52 | P3 | `cDIngameLogOverlay` | `DScript Overlays.nut:41, 70` (2nd occurrence was `:73` before the 2026-08-04 merge) | `Y = SizeX.tointeger() + Y` — should be `SizeY`. Negative-Y log positioning is wrong (duplicated in constructor and `OnUIEnterMode`). *Also new since that merge:* the `kIngameLogAlpha` constant was renamed to `kGameLogAlpha` (cosmetic, all call sites updated together), and the `UpdateTOverlaySize` call inside `DrawTOverlay` (`:87`) is now commented out — worth confirming in DromEd whether the background box still resizes correctly, since nothing else appears to size it after creation | `SizeY`; also de-duplicate the two identical blocks | ☐ | + +--- + +## Group F — Debug leftovers & dead code (P2/P3) + +Do this before any performance work — `DCheckString` is the hottest function in the framework. + +| ID | Priority | Location | Problem | Status | +|---|---|---|---|---| +| T-60 | P2 | `DScript Core.nut:1222, 1242, 1289, 1294, 1426` | Unconditional `print()` inside `DCheckString`, called for every parameter of every script | ☑ done `29f3bb7` | +| T-61 | P3 | `DScript Core.nut:1596-1597` | `if (this.getclass().getbase() == "DTrigger") print("yohoho")` — class-vs-string compare, never true | ☑ done `29f3bb7` — the `if` went with the print | +| T-62 | P3 | `DScript Core.nut:2218, 2333, 2770, 2805, 2864` | Stray `print()` / `"DID BEGIN"` / `"DID SIM"` | ☑ done `29f3bb7` — also the InitQVarFromProp traces and two `kDoPrint` DPrints that wrote on-screen text in game | +| T-63 | P3 | `DScript SFX.nut` (`DDirector`), `DScript_ModdingTools.nut` (`DPerformanceTest`) | Several development `print()` calls | ☑ done `f284495` — DDirector's seven prints removed. `DPerformanceTest`'s prints are the tool's own output and were kept; a leftover top-level scratch snippet that printed on every compile was removed instead | +| T-64 | P3 | `DScript Core.nut:891` | Unreachable statement after `return` in `SetQVar` — was it meant to replace the line above? | ☐ | +| T-65 | P3 | `DScript Core.nut:2018-2019` | Unreachable block after `return false` — carries a real TODO about per-frame `{Off}` support (see T-70) | ☐ | +| T-66 | P3 | `DScript SFX.nut:1603` | `if (true || DGetParam(_script + "FixedTime"))` — forced branch, `DDirector` non-fixed-time path is unreachable | ☐ | +| T-67 | P3 | `DScript Core.nut:1286-1295` | `>` file operator: `Engine.FindFileInPath` result is printed (`"yes in "` / `"nope try again"`) but never used — `dblob.open(sref)` runs with an unvalidated path | ◐ prints removed `29f3bb7`, branches kept with TODOs. The unvalidated path is still open | + +--- + +## Group G — Incomplete features (P3) + +Author's own markers, worth knowing before designing anything nearby. + +| ID | Location | Feature | State | Status | +|---|---|---|---|---| +| T-70 | `DScript Core.nut:2015` | Per-frame delay `{Off}` support | Sketched in a dead code path; needs the action flag stored in the registry key | ☐ | +| T-71 | `DScript Core.nut:162-177` | `set dhelp` console help | Both branches are empty stubs; the hello banner at `:152` is also gated behind `DScriptVersion > 0.90` so it never fires at 0.81 | ☐ | +| T-72 | `DScript Core.nut:1287` | `>` operator file lookup | No path caching, no FM-relative resolution (`// TODO cache location, check FM`) | ☐ | +| T-73 | `DScript SFX.nut:17, 113` | `DRayAttach` | Documented but not implemented | ☐ | +| T-74 | `DScript SFX.nut:158-159, 200` | `DArmAttachmentUseObject` modes 2 and 3 | Author labels them "experimental and not really working" / "little working" | ☐ | +| T-75 | `DScript SFX.nut:794-808` | `DSubInventory` auto-remove-when-empty | Implemented then commented out, marked `#NOTE Discontinued` | ⊘ | +| T-76 | `DScript File&Blob.nut:48` | CRLF handling in `dfile.getParam` | `+1` char per line on Windows line endings; author unsure whether it was fixed | ☐ | +| T-77 | `DScript File&Blob.nut:797, 814` | Backup blob; hex-string→int conversion | Both flagged TODO | ☐ | +| T-78 | `DScript Overlays.nut:28`, `DSConfigDefault.nut:110, 289` (was `:112, 290` before the 2026-08-04 merge) | Shock 2 support gaps | `#HELP ME`: correct SS2 log filename, `taglist_vals.txt` compatibility, how `sContainMsg` is generated | ☐ | +| T-79 | `DScript_ModdingTools.nut:229, 552` (was `:232, 554` before the 2026-08-04 merge) | `DAutoTxtRepl` | Subtables always overwritten; `# → 0` range not handled | ☐ | + +--- + +## Group H — Documentation (P2/P3) + +| ID | Priority | Item | Notes | Status | +|---|---|---|---|---| +| T-80 | P2 | `docs/DScript Documentation.pdf` is at **v0.28a**, code is at 0.81 | ~60% of current features are undocumented | ☐ | +| T-81 | P2 | Document the new operators | `]` `{}` `}` `?` `->` `/` `>` `_` `&%…%` `&-` `&<` `` `[culprit]` `[player]` `[message]` `[item]` `[weapon]` `[random]` `[archetype]` `[name]` `[position]` `[rotation]` `[copy]` | ☐ | +| T-82 | P2 | Document the new universal parameters | `Condition` / `OnCondition` / `OffCondition`, `ExclusiveMessage`, `Copies`, `Debug`, frame delays (`Delay="3F"`) | ☐ | +| T-83 | P2 | Document the QVar system | `eDQVarType`'s 7 storage tiers, `DTrapSetQVar`, `DTrigQVar`, `DTrapDeleteQVar` | ☐ | +| T-84 | P3 | Document the new scripts | `DTrigger`, `DScriptHandler`, `DObjectFaceTarget`, `DObjectPanTo` (renamed from `DFocusObject`/`DFocusOverTime` by the 2026-08-04 merge), `DDirector`, `DHudObject`, `DInventoryMaster` family, `DRenameItem`, `DTweqDevice`, `LootSounds`, `DSpy`, `DAutoTxtRepl`, `DPerformanceTest` | ☐ | +| T-85 | P3 | Document the `_dFROM` / `[source]` fix | `DSConfigDefault.nut:270-322` patches every `sScrMsg` subclass so `[source]` works for Frob/Contained/Stim/Phys/Room/Damage. Only mentioned in a git log line | ☐ | +| T-87 | P2 | `docs/KNOWN_ISSUES.md` — user-facing list of what is known-broken in the alpha | Generated from the two wave SUMMARYs: non-functional scripts, works-but-wrong scripts, `Copies` breakage, SS2 gaps, the editor-only/`DTestTrap` packaging trap. Keep it in sync when Group B/E rows get fixed | ☑ done — new file | +| T-86 | P3 | README | Still advertises "v1.0 is coming" and the `scripts-in-progress` branch; should point at V2, the config-file layering, and the Notepad++ language file | ☑ done — rewritten for the alpha, incl. the intended shipped file set and a link to `KNOWN_ISSUES.md` | + +--- + +## Group I — Design risks (no action yet, decide before refactoring) + +| ID | Location | Risk | Status | +|---|---|---|---| +| T-90 | `DScript Core.nut:926, 629` | `_GetInstance()` / `_tempstore._get` walk the call stack with **hard-coded depths (4, 5, 7)**. Any added or removed call frame silently breaks variable resolution inside `_` expressions. No test can catch this | ☐ | +| T-91 | framework-wide | `RepeatForCopies` mutates `_script` on the live instance and re-enters the caller; `DTrigger` also appends/slices `"T"`. Several sites juggle `_script` by hand and must restore it — a missed restore corrupts every later parameter lookup on that instance | ☐ | +| T-92 | `DScript Core.nut:1619-1640` | `Copies` is limited to 2–9 by single-character arithmetic (`_script[-1]`, `+ '0'`) | ☐ | +| T-93 | repo-wide | Encodings have drifted: only `DScript Core.nut` and `DScript File&Blob.nut` are still ANSI/Latin-1, the rest are UTF-8, and `§` is a literal `case` label at `Core:1172`. Whether NewDark tolerates the UTF-8 files is unverified | ☐ | + +--- + +## Suggested order + +1. ~~**T-01**~~ — resolved by the 2026-08-04 merge, no longer blocks anything. +2. **Group B** (T-10…T-19) — one-word edits, each restores a dead feature. +3. **T-30, T-31** — recursion; T-30 fires on a common event. +4. **T-41, T-42** — `DImUndercover` is completely unmoded today. +5. **T-20, T-21** — parser bugs; everything downstream depends on them. +6. **T-60** — strip the hot-path prints before touching `DPerformanceTest`. +7. Then Groups D/E by priority, and Group H alongside. diff --git a/docs/review/wave1/SUMMARY.md b/docs/review/wave1/SUMMARY.md new file mode 100644 index 0000000..95012c0 --- /dev/null +++ b/docs/review/wave1/SUMMARY.md @@ -0,0 +1,100 @@ +# DScript V2 Review — Wave 1: Base Scripts & `DScript` Namespace + +**Scope:** `DScript Core.nut:188-2732` — the `DScript` library table plus the base class hierarchy +`DBasics` → `DBaseTrap` → `DRelayTrap`/`DTrigger` → `DScriptHandler` → `DHub`. Everything else +(General.nut, SFX.nut, File&Blob.nut, Overlays.nut, ModdingTools.nut, and the QVar trap classes at +Core.nut:2732+) is out of scope for this wave. + +**Method:** 6 agents each statically reviewed one feature against `docs/OPEN_TASKS.md` (to avoid +duplicating already-tracked bugs) and `CLAUDE.md`'s documented Squirrel/NewDark gotchas, producing a +record with five non-exclusive status tags (`complete`, `needs_cleaning`, `incomplete`, +`contains_bugs`, `has_suggestions`). Every bug claim then went through an independent adversarial +verification pass — a second agent re-read the surrounding code trying to refute it, defaulting to +"refuted" on any doubt. Nothing in this repo can be executed, so this is the only verification +available; treat "confirmed" as "survived a skeptical static re-read," not "observed to fail at +runtime." + +Per-feature detail lives in the sibling files in this folder. This file is the roll-up. + +## Headline result + +**All 6 features came back tagged `contains_bugs` + `needs_cleaning` + `incomplete` + +`has_suggestions`. None are clean.** This matches the branch's own header ("not a stable release … +only minimally tested") but the QVar and pingback machinery in particular are more broken in their +common code paths than `docs/OPEN_TASKS.md` currently reflects. + +| Feature | File | Status | Confirmed bugs | Refuted candidates | Cleanup | Incomplete | Suggestions | +|---|---|---|---:|---:|---:|---:|---:| +| [`DScript` (library namespace)](dscript-namespace.md) | Core.nut:188 | 🐛🧹🚧💡 | 11 | 2 | 3 | 1 | 4 | +| [`DBasics`](dbasics.md) | Core.nut:983 | 🐛🧹🚧💡 | 15 | 0 | 3 | 4 | 4 | +| [`DBaseTrap`](dbasetrap.md) | Core.nut:1579 | 🐛🧹🚧💡 | 10 | 0 | 4 | 1 | 4 | +| [`DRelayTrap` + `DTrigger`](drelaytrap-dtrigger.md) | Core.nut:2062 | 🐛🧹🚧💡 | 4 | 1 | 2 | 2 | 4 | +| [`DScriptHandler`](dscripthandler.md) | Core.nut:2229 | 🐛🧹🚧💡 | 8 | 1 | 6 | 4 | 4 | +| [`DHub`](dhub.md) | Core.nut:2538 | 🐛🧹🚧💡 | 7 | 2 | 2 | 1 | 5 | +| **Total** | | | **55** | **6** | **20** | **13** | **25** | + +(🐛 contains bugs · 🧹 needs cleaning · 🚧 incomplete · 💡 has suggestions) + +Of 61 candidate bug claims, 55 (90%) survived adversarial verification; 6 were refuted (kept in each +feature's file under "Candidate findings rejected on verification" for transparency, since a refuted +claim can still flag a genuinely confusing piece of code worth a second look later). + +## Most important new findings (not already in `docs/OPEN_TASKS.md`) + +These are the highest-severity, previously-untracked defects across the wave — worth triaging before +the next wave, independent of `docs/OPEN_TASKS.md`'s existing suggested order: + +1. **`DScript.SetQVar` calls a nonexistent `DScript.Quest.QuestChange` member** for every non-default + QVar type (Core.nut:890) — the intended notification line after it is unreachable dead code. This + means most real `SetQVar` calls throw instead of writing the QVar. +2. **`DScript._GetQVarType` and `DeleteQVar` both operate on values that can be `null`** in ordinary + use (an unguarded `::Quest.BinGetTable()` result at :712, and relational compares against a + default `type=null` parameter at :899/:915) — breaking the first QVar read of a campaign, and + `DTrapDeleteQVar`'s default (no explicit `Type`) call pattern. +3. **`_tempstore.APPEND`, which backs the `"` append operator, silently returns `null` instead of the + appended result** for arrays/strings in the common (non-overflow) case (Core.nut:549) — every + `"`-style append that doesn't exceed `maxLength` is effectively discarded by its caller. +4. **`DBaseTrap`'s `Capacitor`/`OnCapacitor` combination has an asymmetric guard**, letting an action + fire before its general `Capacitor` is actually full when both are combined on one trap. +5. **`DBasics.DCheckString` has no empty-string guard** (its only attempt is commented out) and + indexes `str[0]` unconditionally — any Design Note parameter written as `Foo=;` (empty value) + throws instead of returning a sane default. +6. **`DHub`** — beyond the already-documented T-13/T-14/T-40, this pass found 7 confirmed distinct + failure modes, reinforcing that a full rewrite (not a patch) is the realistic path once T-13/T-14 + are fixed and the class is re-reviewed as CLAUDE.md's T-40 suggests. + +## Cross-cutting themes + +- **`find()`-returns-null-not-0 is not a one-off.** Beyond the two instances already tracked as T-23, + this wave found the same mistake recurring independently in `DGetStringParamRaw` (namespace) and + in `DCheckCondition`'s operator detection (`DBaseTrap`). Worth a dedicated grep sweep for + `.find(` across the whole codebase before the next wave, rather than fixing occurrences one at a + time as they're found. +- **QVar write/delete paths look considerably more broken than read paths.** All three of the + headline QVar findings above are in `Set`/`Delete`, not `Get`. Anything downstream that depends on + QVars actually being written (which is most of the framework, per `CLAUDE.md`'s own description of + the QVar system) should be treated as unverified until these are fixed. + Depends on: `DTrapSetQVar`/`DTrigQVar`/`DTrapDeleteQVar` (Core.nut:2732-2972) are next-wave scope — + triage them with this in mind. +- **Debug/log leftovers are pervasive, not isolated to the already-tracked T-60/T-61/T-62.** All 6 + features were tagged `needs_cleaning`; `DScriptHandler` alone had 6 distinct cleanup items. +- **"Incomplete" features cluster around save/load and per-frame timing** — `DScriptHandler`'s + registry rebuild-on-load path and `DBaseTrap`'s per-frame `{Off}` support (already tracked as T-70) + both surfaced again independently as incomplete rather than merely buggy. + +## What this wave did not cover + +- The QVar trap classes (`DTrapSetQVar`, `DTrigQVar`, `DTrapDeleteQVar`, Core.nut:2732-2972) — + logical next step given the QVar findings above. +- `DScript General.nut`, `DScript SFX.nut`, `DScript File&Blob.nut`, `DScript Overlays.nut`, + `DScript_ModdingTools.nut` — all deferred to later waves. +- Runtime/behavioral confirmation in DromEd — nothing here has been run; every "confirmed" bug is a + static-reasoning conclusion, not an observed failure. + +**Note (2026-08-04):** `origin/DScript-2` (76 diverged commits) was merged into this branch after this +wave was written. `DScript Core.nut` and `DScript General.nut` — this wave's entire scope — came +through byte-for-byte unchanged, so every finding and line reference above is still accurate. The +merge did touch the four deferred files above (`DScript SFX.nut`, `DScript Overlays.nut`, +`DScript File&Blob.nut`, `DScript_ModdingTools.nut`) plus `DSConfigDefault.nut`, and deleted the +legacy `DScript.nut`/`DSEditorScripts.nut` monolith — see `docs/OPEN_TASKS.md` for what changed +there. Next wave's line numbers against those four files should be taken from the post-merge tree. diff --git a/docs/review/wave1/dbasetrap.md b/docs/review/wave1/dbasetrap.md new file mode 100644 index 0000000..f182c41 --- /dev/null +++ b/docs/review/wave1/dbasetrap.md @@ -0,0 +1,89 @@ +# DBaseTrap + +**File:** `DScript Core.nut` · **Anchor:** line 1579 + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +DBaseTrap (DScript Core.nut:1579-2048) is the message-routing and staging engine that nearly every trap in the framework extends: On/Off match -> DCheckCondition -> DCheckParameters (FailChance/Capacitor/Count/Delay) -> DoOn/DoOff, plus the Copies-walking machinery (RepeatForCopies), timer-driven delayed activation (OnTimer), and the DPingBack reply protocol. By inspection the staged pipeline is coherently structured and, for the common single-instance/single-capacitor case, appears internally consistent. However it already carries several previously-tracked, real defects that land squarely in this class (T-10's dead pingback reply variable, T-21's always-true equality-condition mis-check, T-37's editor-only Count/Capacitor init, T-70/T-65's missing per-frame Off support, T-92's 9-copy ceiling, T-38's unverified ExclusiveDelay interaction, T-61's dead debug print, and the general T-91 _script-mutation risk embodied here), and this pass additionally surfaced two not-yet-tracked defects (a Capacitor/OnCapacitor override bug that can let an action fire before a general Capacitor is full, and a recurrence of the find()==0-is-falsy mistake in DCheckCondition's ||/&&/== operator detection) plus some dead/vestigial code and a stale Help string. Given how central this class is, T-10 and T-21 in particular silently disable entire user-facing features (the `/` ping-back chain operator, and `==` Conditions) for every script built on DBaseTrap. + +## Confirmed bugs (10) + +### Line 1596 — Constructor's DTrigger check compares a class object to a string, so it is always false (dead debug print). (tracked: T-61) + +- **Severity:** P3 +- **Failure scenario:** Any DTrigger-derived (or any) script is constructed; `this.getclass().getbase() == "DTrigger"` compares a class-typed value to a string, which is always false in Squirrel, so the branch never executes. Harmless by itself (only suppresses a stray print), but shows no functioning base-class detection exists here if one was ever intended for real logic. +- **Verification:** Confirmed by reading DScript Core.nut:1594-1598. `this.getclass()` returns a class-typed object and `.getbase()` returns that class's base as a class object (not a string), so `this.getclass().getbase() == "DTrigger"` compares a class object to a string literal. Squirrel's `==` for class instances/objects does not coerce to string comparison, so this is always false regardless of DTrigger membership, making the `print("yohoho")` on line 1597 permanently dead code. This matches docs/OPEN_TASKS.md T-61 verbatim (line 129: `DScript Core.nut:1596-1597` ... "class-vs-string compare, never true"), confirming location, cause, and severity P3. + +### Line 1599 — ConstructParameters (Count/Capacitor setup) is skipped entirely outside the editor, so runtime-created copies of any DBaseTrap-derived script never get working Count/Capacitor state. (tracked: T-37) + +- **Severity:** P3 +- **Failure scenario:** A script spawns a new object with a DBaseTrap-derived script class at runtime (e.g. via DAddScript or Object.Create) whose Design Note has `[Script]Count=3`; because `if (!::IsEditor()) return` fires before ConstructParameters()/base.constructor() run, `_script+"Counter"` is never SetData'd, so `IsDataSet(_script+"Counter")` in DCheckParameters stays false forever and the Count limit is silently never enforced on that instance. +- **Verification:** Confirmed genuine bug. In DBaseTrap.constructor (Core.nut:1594-1604), `_script` is set at line 1595, then line 1599's `if (!::IsEditor()){ return }` exits before ConstructParameters() (line 1602) or base.constructor() (line 1603) run. ConstructParameters (1606-1615) is the sole place that SetData's `_script+\"Counter\"`/Capacitor slots based on the Design Note's Count/Capacitor params; DCheckParameters gates Count enforcement on IsDataSet(_script+\"Counter\"). Since IsEditor() is false at actual runtime in-game, any DBaseTrap-derived script constructed at runtime (e.g. via DAddScript/Object.Create) never gets these slots set, so Count/Capacitor is silently never enforced — exactly the claimed scenario. The developer's own comment at line 1607 ('possible TODO: Counter, Capacitor objects will not work when created in game!') independently corroborates this. docs/OPEN_TASKS.md line 96 lists T-37 at Core.nut:1607 with matching description, mechanism, and P3 severity — the 1599 vs 1607 line citation is just two anchors in the same constructor/ConstructParameters pair, not a misattribution. + +### Line 1619 — RepeatForCopies/FrameUpdate mutate the live `_script` field in place to iterate copies, a framework-wide fragile pattern that any future call site must restore correctly or every later parameter lookup on that instance breaks. (tracked: T-91) + +- **Severity:** P3 +- **Failure scenario:** A future edit inserts a new call between DBaseFunction and DoOn/DoOff (or a call that can throw) that reads `_script+"SomeParam"` without going through this exact restore path; if it runs before `_script` is reset back to GetClassName() (lines 1630/1677), that read (and every subsequent one on this instance, for any copy) silently uses the wrong copy's Design Note parameters until a full activation cycle happens to reset it. +- **Verification:** Confirmed by direct reading. `RepeatForCopies` (DScript Core.nut:1619-1640) mutates the shared `_script` field in place (lines 1626/1629/1630/1633) and only resets it to `GetClassName()` once the deepest recursive call reaches the last copy (line 1630); `FrameUpdate` (1675-1677) does an unguarded set/DoOn/reset with no try/catch; and `DTrigger.TriggerMessages` (2180-2193) manually appends/slices a trailing "T" across three different exit branches. None of these sites use try/catch, so an exception thrown out of `DoOn`/`DoOff`/`DCheckParameters`/`DRelayMessages` while `_script` holds a mid-copy or "T"-suffixed value would propagate up without executing the restore line, leaving `_script` corrupted for all subsequent `_script+"Param"` lookups on that instance until a full non-throwing cycle or a save/reload (which reconstructs the instance and resets `_script` at line 1595) fixes it. This is exactly the mechanism described in docs/OPEN_TASKS.md's T-91 entry ("RepeatForCopies mutates _script on the live instance... Several sites juggle _script by hand and must restore it — a missed restore corrupts every later parameter lookup"), so the claim's tie to T-91 checks out and the P3/fragile-pattern characterization is accurate rather than speculative. + +### Line 1628 — RepeatForCopies increments the trailing digit of _script with single-character arithmetic, capping usable Copies at ASCII digits 2-9. (tracked: T-92) + +- **Severity:** P3 +- **Failure scenario:** A script sets `[Script]Copies=10` or higher, intending 10+ parallel instances on one object. `(current + 1).tochar()` only ever walks '2'..'9', and the terminal check `current == Copies + '0'` cannot match a two-digit Copies value, so the recursive copy-walk either never satisfies its stop condition as configured or silently behaves as if capped at 9 copies regardless of the requested count. +- **Verification:** Confirmed at Core.nut:1628-1633. `current = _script[-1]` (line 1628) yields the raw ASCII code of the last character of `_script` (an integer in Squirrel), `_script = GetClassName() + (current + 1).tochar()` (line 1633) advances it one character at a time, and the stop test on line 1629 is `current == Copies + '0'`. Tracing it (e.g. Copies=10): the walk goes ...Button8, Button9, then Button: (colon, ASCII 58) — the loop does mathematically terminate (no infinite recursion/no capping at 9 in the literal sense), but the suffix used for copy #10 is the non-digit character ':' rather than the decimal string "10" a script author would naturally write in the Design Note as `Button10Count=...`. Since that parameter name is never produced, any Copies value ≥10 silently loses access to its per-copy parameters via the normal typing convention — i.e. usable/intuitive Copies is effectively capped at single ASCII digits 2-9, exactly as tracked in docs/OPEN_TASKS.md:186 (line shifted from :178 by the 2026-08-04 merge) (T-92: "Copies is limited to 2–9 by single-character arithmetic (_script[-1], + '0')"). The failure_scenario's phrasing about the stop condition "never being satisfied" is technically imprecise (it does terminate, just via a non-digit character), but the underlying defect — single-character arithmetic breaking multi-digit Copies — is real, correctly located, and matches the tracked issue. + +### Line 1707 — OnDPingBack replies using the undefined variable `intern` instead of the declared `inter`, throwing whenever msg.data is used. (tracked: T-10) + +- **Severity:** P1 +- **Failure scenario:** Any script receives a DPingBack message with msg.data set (the `/` chain operator use case). DCheckString's result is stored in local `inter`, but the immediately following `if (!intern)` / `Reply(intern)` reference an undefined identifier `intern` — this throws/silently fails at runtime, so no reply is ever produced and the entire `/` ping-back chain operator is dead for every script that extends DBaseTrap. +- **Verification:** Read lines 1689-1730 of "DScript Core.nut". Line 1706 declares `local inter = DCheckString(bmsg.data)`, but line 1707 checks `if (!intern)` and line 1709 calls `Reply(intern)` — `intern` is never declared anywhere in this scope, only `inter` is. In Squirrel an unresolved bare identifier is not silently treated as a global null; it triggers a runtime "the index 'intern' does not exist" error when the compiler falls through to a table/member lookup, so this branch throws whenever `bmsg.data` is truthy (the exact scenario used by the `/` chain operator, per line 1705's own comment "Especially for the / operator this returns false"). Nothing upstream guards against this — `replies[0] == null` only short-circuits the earlier custom-message-absent case, not this one — so the failure scenario as described is accurate. This exactly matches docs/OPEN_TASKS.md T-10 ("DScript Core.nut:1707,1709 ... local inter = … then if (!intern) / Reply(intern) ... intern → inter", P1), confirming the finding is both real and correctly cross-referenced. + +### Line 1851 — DCheckCondition's ||, && and == operator detection uses `if (Condition.find(...))`, so an operator literally at string index 0 is misread as 'operator absent' (the recurring find()==0-is-falsy bug elsewhere in the file). (new finding) + +- **Severity:** P3 +- **Failure scenario:** A Design Note condition is authored as `Condition="||SomeQVar"` (empty left operand, intending 'always-true or SomeQVar'). `Condition.find("||")` returns 0, and `if (condtype)` treats that 0 exactly like 'not found' (same mistake as the already-tracked T-23 pattern), so the code falls through past the intended 'find any' branch into the `&&`/`==`/plain-match checks instead. The identical mistake recurs for `&&` at line 1863 and `==` at line 1874. +- **Verification:** Confirmed by reading DScript Core.nut:1840-1891. Lines 1851, 1863, 1874 each do `local condtype = Condition.find("||"/"&&"/"==")` followed immediately by `if (condtype){...}` (1852, 1864, 1875). Squirrel's find() returns the substring index or null-when-absent, and integer 0 is falsy in Squirrel (the same convention CLAUDE.md flags for the tracked T-23 bug at lines 453/485, and contrasted correctly at lines 1802/1813 in this very file which use `.find(mssg) != null`). So a Condition string where the operator sits at index 0 (e.g. `"||SomeQVar"`, `"&&SomeQVar"`, `"==SomeQVar"`) makes `condtype` equal 0, `if(condtype)` is false, and execution falls through past the intended branch into the next operator check and ultimately into the plain-match fallback at line 1890, exactly as the failure scenario describes. I checked docs/OPEN_TASKS.md and this exact defect (lines 1851/1863/1874) is not covered by any existing entry: T-21 (line 1879, `cond1.len() != cond2.len` missing parens) is a different bug in a different sub-branch, and T-23 (lines 453/485) is the same bug class but at unrelated lines. So this is a real, verifiable, previously-untracked instance of the find()==0-falsy pattern. + +### Line 1879 — DCheckCondition's '==' branch compares cond1.len() to the cond2.len method reference (missing parens), so the equality pre-check is always true. (tracked: T-21) + +- **Severity:** P1 +- **Failure scenario:** A trap uses `[Script]Condition="A==A"` intending an equality test between two matching object/QVar sets. `cond1.len() != cond2.len` compares an integer to a closure value and is always true, so DCheckCondition always takes the 'not equal' branch regardless of the sets' actual contents/lengths — a `==` Condition can never evaluate as a true match. +- **Verification:** Confirmed at DScript Core.nut:1879: `cond1.len() != cond2.len` calls len() on cond1 but leaves cond2.len as an unbound closure reference (missing parens). In Squirrel, `!=` between an integer and a closure/method value is always true (different types), so this branch unconditionally executes `return negate? true : false` at line 1880, short-circuiting before the real element-comparison loop at lines 1882-1886 ever runs. This means a `[Script]Condition=\"A==A\"` can never evaluate true regardless of set contents, exactly as claimed. This matches docs/OPEN_TASKS.md T-21 (line 75) verbatim in location, description, and suggested fix (`cond2.len()`), confirming the tracked-issue link. + +### Line 1936 — In DCheckParameters, a still-counting general Capacitor's abort decision can be silently overwritten to 'proceed' by a later OnCapacitor/OffCapacitor check reaching its own threshold in the same call. (new finding) + +- **Severity:** P2 +- **Failure scenario:** A trap defines both `[Script]Capacitor=5` (general) and `[Script]OnCapacitor=2` on the same object. On a TurnOn message where the general capacitor is still counting (DCapacitorCheck returns true, so abort=true) but the OnCapacitor happens to hit its own threshold this same call (DCapacitorCheck returns false), the unconditional `else {abort=false}` on line 1937 overwrites abort back to false, so DoOn() fires even though the general Capacitor was never satisfied — the opposite of the inline comment's stated intent that 'a On/Off capacitor can't interfere with the general one'. +- **Verification:** Reading DScript Core.nut:1934-1938 confirms the bug exactly as claimed. DCapacitorCheck returns a strict boolean (true = still counting/abort possible, false = threshold reached), so no falsy-zero ambiguity is involved. Line 1936 sets abort=true when the general Capacitor is still counting. Line 1937's else-branch (`else {abort=false}`, taken when DCapacitorCheck(DN,"On") returns false, i.e. OnCapacitor just hit its own threshold) unconditionally overwrites abort with no guard — unlike the true-branch, which is guarded by `if (abort==null)` to avoid clobbering an existing decision. This asymmetry means a still-counting general Capacitor's abort=true is silently reset to false the moment OnCapacitor (or OffCapacitor on line 1938, symmetric issue) reaches its own threshold in the same call, directly contradicting the inline comment's stated intent ("a On/Off capacitor can't interfere with the general one") and letting DoOn()/DoOff() fire prematurely. The constructor (lines 1611-1613) confirms both `_script+"Capacitor"` and `_script+"OnCapacitor"` IsDataSet flags can coexist when both parameters are defined >1, so the claimed dual-parameter scenario is reachable. docs/OPEN_TASKS.md has no matching entry (only T-37, about a different capacitor init limitation), supporting the "new issue" claim. + +### Line 1985 — Author's own TODO: unverified whether ExclusiveDelay correctly cancels an infinite-repeat delay timer without restarting it. (tracked: T-38) + +- **Severity:** P3 +- **Failure scenario:** A script sets `[Script]Delay="2"` with `Repeat=-1` (infinite) and `ExclusiveDelay=1`; on a re-activation the old timer is killed via KillTimer, but the inline TODO states it's unconfirmed whether this stops the repeat cleanly or can leave a stale/duplicate delay cycle — flagged by the author as unverified, not merely undocumented. +- **Verification:** The TODO's suspicion is confirmed by tracing the actual control flow, not just restating an author's doubt. Line 1985 unconditionally does `KillTimer(GetData(_script+"DelayTimer"))` whenever ExclusiveDelay is set and a DelayTimer exists — this runs before the InfRepeat handling that follows. OnTimer (lines 1744-1757) shows that during an infinite repeat, "DelayTimer" always holds the ID of the *next scheduled* firing (each firing reschedules and overwrites DelayTimer via SetData at line 1754), so killing it truly cancels the repeat's continuation. Then, in the "same command received" case (line 1999-2006), when `GetData(InfRepeat) == ScriptAction`, the code intentionally falls through to `return 0` without calling `DSetTimerData`/`SetData(DelayTimer,...)` again — per the comment at 1989-1991 this branch is meant to "do nothing," but ExclusiveDelay's unconditional kill at line 1985 has already destroyed the pending continuation timer with nothing to replace it. So a same-action re-trigger while an infinite-repeat delay is active, with ExclusiveDelay=1, silently and permanently stops the repeat instead of leaving it untouched — a genuine, traceable defect confirming the author's TODO, not merely an unresolved documentation gap. + +### Line 2018 — Dead code intended to store the On/Off action alongside the per-frame registration key never runs, so per-N-frame delayed traps only ever call DoOn(), never DoOff(). (tracked: T-70) + +- **Severity:** P2 +- **Failure scenario:** A script uses `[Script]Delay="3F"` and later receives the matching Off message, intending the recurring per-frame action to switch from DoOn to DoOff. FrameUpdate() (line 1676) unconditionally calls `DoOn(userparams())` on every registered frame because no action flag is ever stored in `_script+"InfRepeat"` for the per-N-frame path — the line that would store it (2018) is unreachable, sitting after `return false` on line 2016 — so per-frame DoOff behaviour can never be selected. +- **Verification:** Confirmed by direct reading. In DBaseFunction's per-N-frame delay branch, line 2014 stores only the registration key via SetData(_script+\"InfRepeat\", ::DHandler.PerFrame_Register(...)), then line 2016 unconditionally `return false`s out of the function. Line 2018, which would instead store `ScriptAction + ::DHandler.PerFrame_Register(...)` (encoding the on/off action alongside the key), is textually after the return and thus unreachable — confirmed dead code, not a truthiness/precedence misread since Squirrel has no fallthrough past `return`. FrameUpdate (lines 1672-1678) then unconditionally calls `DoOn(userparams())` on every registered per-frame callback with no read of any stored action flag, so DoOff can never be selected for the per-N-frame delay path — the claimed failure scenario holds. This exactly matches OPEN_TASKS.md T-70 (\"Sketched in a dead code path; needs the action flag stored in the registry key\", DScript Core.nut:2015) and its cross-referenced T-65 (\"Unreachable block after return false\", lines 2018-2019), so the finding's issue-tracker linkage is also accurate. + +## Cleanup items (4) + +- **Line 1584:** Help2 metadata documents a parameter name, "DBaseTrapBlockMessage=", that doesn't exist anywhere in the code; the actual message-blocking parameter implemented below is `_script+"ExclusiveMessage"`. +- **Line 1598:** Commented-out leftover debug print left in the constructor. +- **Line 1662:** Dead/unreachable `else ::DHandler.PerMidFrame_ReRegister(this)` branch in OnBeginScript: `_script+"InfRepeat"` is only ever set to an "F"+key string (line 2014) or a plain integer ScriptAction (line 2023) anywhere in DBaseTrap, so `typeof data == "string"` always implies `data[0]=='F'` — the PerMidFrame_ReRegister call can never actually be reached through this code path. +- **Line 1833:** Bare, unconditional `print()` for the "_script NOT SET" safety warning, inconsistent with the DPrint/ePrintTo logging convention used throughout the rest of the class. + +## Incomplete items (1) + +- **Line 1656:** True per-frame (every single engine frame) re-registration is unreachable through DBaseTrap's own Delay-parameter pipeline. — Only the per-N-frame ("NF") format ever gets registered/reregistered via OnBeginScript's InfRepeat check, since DCheckParameters only ever calls PerFrame_Register (never PerMidFrame_Register) from the Delay branch. Classes wanting genuine every-frame updates (e.g. DHudObject in DScript SFX.nut) must call ::DHandler.PerMidFrame_Register directly, bypassing this framework stage entirely — making the OnBeginScript branch that would reregister a mid-frame InfRepeat vestigial within DBaseTrap itself. + +## Suggestions (4) + +- **Cache the FailChance DGetParam result instead of evaluating it twice (lines 1929 and 1931, similarly 1963/1964).** — Harmless today, but the author's own comment acknowledges the double evaluation; if FailChance is ever driven by a non-deterministic parameter expression (e.g. a random-selection operator), the two evaluations could return different values and produce inconsistent Count/Capacitor bookkeeping versus the actual fail decision. +- **Make the Capacitor/OnCapacitor/OffCapacitor combination logic symmetric, or document that combining them on one trap is unsupported.** — The true-branch is guarded with `if (abort==null)` but the false-branch is unconditional, which is the root cause of the capacitor-override bug reported above; a symmetric guard (or an explicit doc note against combining the two capacitor types) would remove the ambiguity. +- **Re-verify the hard-coded call-stack depths in _GetInstance/_tempstore._get (T-90) specifically against DCheckCondition's '_' operator dispatch path.** — DCheckCondition's `_` branch calls ::DScript.CheckAndCompileExpression(this, ...), adding a call frame on top of the already depth-sensitive stack walk; any change to this dispatch chain is a plausible way to silently break variable resolution inside `_` expressions. +- **Route the '_script NOT SET' safety print (line 1833) through DPrint with an explicit ePrintTo level.** — Consistency with the rest of the class's logging conventions, and lets the message respect existing debug/log toggles instead of always printing unconditionally. diff --git a/docs/review/wave1/dbasics.md b/docs/review/wave1/dbasics.md new file mode 100644 index 0000000..dc75a05 --- /dev/null +++ b/docs/review/wave1/dbasics.md @@ -0,0 +1,121 @@ +# DBasics + +**File:** `DScript Core.nut` · **Anchor:** line 983 + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +DBasics (DScript Core.nut:983-1569) is the root of the whole class hierarchy and houses DCheckString — the parameter-string parser that every single script in the framework routes every Design Note value through — plus DGetParam/DGetParamRaw, the timer-data helpers, and DPrint. Structurally it is complete (every documented operator sigil has a case, and the general dispatch/formatting scheme via _FormatForReturn is coherent), but it is riddled with concrete, previously-untracked defects on top of the roughly eight already-tracked OPEN_TASKS items that touch this exact code (T-11, T-12, T-19, T-20, T-24, T-26, T-60, T-67). Several of the new findings are severe and match exactly the bug classes the codebase is already known for: split() dropping empty leading/interior tokens breaks the '' vector operator (throws) and the whole '>' file-lookup operator's field indexing (misparses even the examples in its own inline comments); array(size, containerFill) aliasing silently breaks the '{' box-distance filter's min/max limits; three call sites drop the returnInArray flag when re-wrapping via _FormatForReturn; and DCheckString has no live guard against an empty-string input even though the author clearly considered one (it's commented out immediately above the crash site). DPrint's Debug-flag lookup also violates the framework's own _script-prefix convention, breaking per-Copies/DTrigger debug toggling. None of this can be run or tested in this repo — all findings are from static reading only. + +## Confirmed bugs (15) + +### Line 1033 — DCheckString has no guard for an empty string and immediately indexes str[0]; the only guard the author sketched for this case is commented out. (new finding) + +- **Severity:** P1 +- **Failure scenario:** DGetParam/DCheckString is called with a Design Note value that is a literal empty string (e.g. a parameter written as 'Foo=;' with nothing between '=' and ';', or any defaultValue of ""). typeof(str)=="string" so it falls through the first switch, then 'switch (str[kGetFirstChar])' indexes str[0] on a zero-length string, which Squirrel raises as an out-of-range index error. The dead code right above it (lines 1025-1031, a commented-out 'case "":' block that would have printed a warning and returned [""]/"") shows the author intended to handle this but the guard was never wired in live. +- **Verification:** Confirmed by reading DScript Core.nut:1003-1033: DCheckString's type switch (1012-1023) only special-cases null/bool/vector/float/integer/array, not string; the empty-string guard is dead code (commented out, 1025-1031); execution falls straight into `switch (str[kGetFirstChar])` at 1033, i.e. `""[0]`, which Squirrel's string indexing raises as an out-of-range runtime error. This isn't merely a hypothetical Design-Note edge case — DGetParam (Core.nut:1499-1507) returns `DCheckString(defaultValue, returnInArray)` whenever the queried key is absent from userparams(), and active V2 code passes a literal "" defaultValue at real call sites: DScript General.nut:191 (`DGetParam(_script+"TOnResult","",DN)`) and :199 (`TOffResult`), plus DScript SFX.nut:995/1054 (`Append`). Since TOnResult/TOffResult/Append are optional parameters commonly left unset, the common/default path for DHitScanTrap hits this exact crash. The claim is a genuine, reproducible-by-inspection P1 defect, correctly locating both the missing guard and the vestigial commented-out fix that shows it was known/intended but never wired in. + +### Line 1059 — '[copy]' operator can index str[6] out of range when used as the bare literal '[copy]' with nothing following it. (new finding) + +- **Severity:** P3 +- **Failure scenario:** For a Design Note value that is exactly "[copy]" (6 characters, valid indices 0-5) with no trailing '{Suffix}' or param-name text, `if (str[6] == '{')` indexes one past the end of the string and Squirrel raises an out-of-range error before the plain-param-name fallback (which reads div_str[1], itself likely an empty string in this scenario) is ever reached. Narrow edge case, but a concrete unguarded crash path. +- **Verification:** Confirmed as a real bug. At Core.nut:1053-1062, the "copy" case is reached via DivideAtNext(str.slice(1), "]") for any string beginning with "[copy" — including the bare literal "[copy]" (len 6, valid indices 0-5). Line 1059 then evaluates str[6] against the original (unsliced) `str`, which for the bare-literal case indexes one past the end. I verified against the actual Squirrel VM source (sqvm.cpp Get(), OT_STRING case): it checks `n >= 0 && n < len` and calls Raise_IdxError(key) otherwise — so index 6 on a 6-char string is a genuine out-of-range runtime error, not silently tolerated. Notably, the sibling cases "archetype"/"name"/"position"/"rotation" right below (lines 1066-1081) all use the safe `div_str[1] == ""` idiom to detect the bare-tag case instead of hard-indexing `str`, confirming "copy" is the outlier that skips the safe pattern already established in the same switch. Grep of docs/OPEN_TASKS.md shows no existing T-id at these line numbers or referencing this crash, so it is indeed untracked. This is a narrow, low-impact edge case (P3 is reasonable) since a user would have to write the literal value "[copy]" with nothing appended, but it is a concrete, unguarded crash path exactly as described. + +### Line 1060 — Three DCheckString branches call the outer ::DScript._FormatForReturn(...) with only one argument, silently dropping the returnInArray flag the caller asked for. (new finding) + +- **Severity:** P2 +- **Failure scenario:** Lines 1060 and 1062 (the '[copy]' sugar operator, both the '{Suffix}' and plain-param-name sub-branches) and line 1477 (the '->Prop:Field' property-get operator) call `::DScript._FormatForReturn()` with no second argument, so it uses its default `inArray=false` regardless of what the caller's `returnInArray` was. _FormatForReturn(param, false) on an array returns `param.top()` (collapses to the last element) instead of the array, and on a scalar returns it bare instead of wrapped in `[param]`. Any parameter expression that nests a '[copy]' or '->Prop:Field' reference inside an array-context operator (e.g. '+A+[copy]{X}', or '}'/'{'/']' filters applied to a '->' property lookup) — all of which call the inner DCheckString with `kReturnArray` — gets a bare scalar or a collapsed-to-last-element result instead of the expected array, corrupting the surrounding set operation (e.g. `.append()`/`.filter()`/`foreach` on a non-array). +- **Verification:** Confirmed by direct reading. Lines 1060 and 1062 wrap the recursive `DCheckString(userparams()[...], returnInArray)` call (which correctly propagates the flag) in an outer `::DScript._FormatForReturn(...)` call with no second argument, so it silently uses the default `inArray=false`; line 1477 (`return ::DScript._FormatForReturn(::Property.Get(anchor,prop_field[0],prop_field[1]))`) omits the flag entirely. Per `_FormatForReturn` at line 213, when `inArray` is false, an array input is collapsed via `param.top()` (or `0` if empty) and a scalar is returned bare — never wrapped in `[param]` — regardless of what the caller requested. This is reachable exactly as claimed: the `+` operator (line 1355-1373) calls `DCheckString(t, kReturnArray)` per token and does `objset.extend(...)` expecting an array, and the `}`/`?` filter operators (lines 1378-1388) call `DCheckString(slice, kReturnArray).filter(...)`, also requiring an array; if `t`/`slice` is a `[copy]{...}` or `->Prop:Field` expression, the buggy lines 1060/1062/1477 return a collapsed scalar instead, breaking `.extend()`/`.filter()` on a non-array. A repo-wide check of `docs/OPEN_TASKS.md` shows no existing T-nn entry referencing `_FormatForReturn` or this collapse behavior, so it is indeed untracked. The finding is accurate and well-scoped. + +### Line 1083 — '[' sugar operator can index str[1] out of range for a 1-character '[' parameter. (tracked: T-26) + +- **Severity:** P3 +- **Failure scenario:** Already tracked as T-26. `if (str[1] == '|')` runs unconditionally after the sugar-keyword switch falls through; if the Design Note parameter is literally the single character '[' (or otherwise shorter than 2 chars), this indexes past the end of the string. +- **Verification:** Confirmed by reading DScript Core.nut:983-1095. In the '[' case (opened at line 1035), str is never reassigned/consumed after the initial dispatch — it remains the full original string. If str is the single character "[" (len 1): the fixed-keyword switch at 1036 doesn't match, DivideAtNext(str.slice(1), "]") at 1053 receives an empty string and (per its implementation at Core:497-506, `find` returns null on no match) safely returns ["",""], so div_str[0].tolower()=="" also fails to match any case in the 1054-1082 switch — no earlier guard or early return intercepts this input. Execution reaches line 1083's `str[1] == '|'` with str.len()==1, and Squirrel's string index operator throws a runtime "index out of range" error rather than returning a sentinel value, so this is a genuine crash path, not merely a truthiness/precedence misreading. This matches docs/OPEN_TASKS.md line 80 (T-26, P3, DScript Core.nut:1083, same description and suggested length-guard fix) verbatim, so the tracked-issue linkage is also accurate. + +### Line 1123 — ']' operator dereferences s[2] and can misparse before validating the split() result has 3 elements — split() drops empty tokens so the intended indices are also off by one. (tracked: T-20) + +- **Severity:** P1 +- **Failure scenario:** Already tracked as T-20. `local s = ::split(str,"]")` drops the empty leading token (str still starts with the ']' dispatch char isn't actually true here — but the '[' prefix before it is consumed elsewhere), landing objset/linkset at s[0]/s[1] while the code reads s[1]/s[2]; s[2] is also indexed at line 1125 before the `s.len()!=3` guard at line 1130, so a malformed ']objs]links' parameter throws before the error print ever runs. +- **Verification:** Confirmed real. The switch dispatches on str[kGetFirstChar] (Core.nut:1033), so entering `case ']'` at line 1122 means str still begins with the ']' delimiter char — unlike sibling cases (e.g. '&' at 1098) this branch never slices it off before use. `::split(str,"]")` (line 1123) therefore sees a leading delimiter and, per Squirrel's documented drop-empty-token behavior (also noted in CLAUDE.md), discards the resulting empty first token, so a well-formed `]objs]links` parameter yields a 2-element array (`s[0]="objs"`, `s[1]="links"`) rather than the 3-element `["","objs","links"]` the code assumes. Line 1125 (`s[2][kGetFirstChar] == '^'`) dereferences `s[2]` unconditionally, and only afterward does line 1130 check `s.len() != 3` — so for the normal/expected input shape this throws an out-of-bounds array access before the diagnostic print ever runs, and even when it doesn't throw, line 1132 reads `s[1]`/`s[2]` as objset/linkset when they actually land at `s[0]`/`s[1]`. This exactly matches the tracked T-20 entry in docs/OPEN_TASKS.md ("parts land at s[0]/s[1] ... s[2] dereferenced at :1125 before the s.len()!=3 guard"), including the suggested fix (reindex + move guard above the deref). + +### Line 1165 — '$' QVar operator falls back to getconsttable().MissionsConstants, a name that is never actually defined (the real table is MissionConstants). (tracked: T-19) + +- **Severity:** P1 +- **Failure scenario:** Already tracked as T-19. DSConfigDefault.nut defines `MissionConstants` (no 's'); DCheckString checks `str in getconsttable().MissionsConstants`, which either throws (slot doesn't exist) or is always false, so the custom-mission-constant fallback of the '$' operator never resolves for any FM-defined constant. +- **Verification:** Confirmed by direct source inspection: DScript Core.nut:1165-1166 references getconsttable().MissionsConstants (with an extra 's'), while DSConfigDefault.nut:71 defines getconsttable().MissionConstants <- {...} (no 's'). Lines 1155-1163 show the QVar check and Engine.ConfigIsDefined check both already failed by the time control reaches 1165, so there's no earlier guard preventing this path from being hit for genuine FM-defined constants. Since getconsttable() returns an immutable table and indexing a nonexistent slot on a Squirrel table throws a runtime "index not found" error (rather than evaluating to false), the `in` check at 1165 will throw for any lookup — matching the claimed failure scenario exactly. This is an exact match to the already-tracked T-19 entry in docs/OPEN_TASKS.md:71 (line shifted from :63 by the 2026-08-04 merge), which cites the same file:line, the same DSConfigDefault.nut contradiction (now at :72, was :71), and even notes DScript_ModdingTools.nut spells it correctly (now at :818, was :821), corroborating the typo theory. + +### Line 1222 — Unconditional print() calls left in DCheckString, the hottest function in the framework, fire on every relevant parameter regardless of Debug settings. (tracked: T-60) + +- **Severity:** P2 +- **Failure scenario:** Already tracked as T-60. Lines 1222 and 1242 (the '/' PingBack chain) and 1289/1294 (the '>' file operator) and 1426 (the '{' distance filter) call the raw Squirrel `print()` unconditionally, spamming monolog.txt for every use of these operators in every mission, independent of any [ScriptName]Debug setting. +- **Verification:** Verified against the actual source. Line 1222 is `print(str+"start?" + start + " On: " + self)` inside the '/' PingBack case, with no surrounding `if` guard (the enclosing `case '/':` block runs unconditionally whenever the operator is used). Line 1242 is `print("SHARED" + obj)` inside a bare `foreach` loop, also unconditional. Lines 1289/1294 (`print("yes in " + sref)` / `print("nope try again")`) sit in the '>' file operator's if/else with no Debug check, and line 1426 (`print(divide[0])`) is inside the '{' distance-filter case, again unguarded. None of these are wrapped in a `[ScriptName]Debug` check or routed through `DPrint` (which does respect print-target flags) — they call the raw Squirrel `print()` directly, so they fire on every invocation of these operators in every mission. This exactly matches docs/OPEN_TASKS.md T-60, which lists the identical five line numbers and same description. The claim is accurate and not a misreading. + +### Line 1251 — The '>' file/string-resource operator's whole divide[] index scheme assumes split() keeps empty tokens, but it drops them, shifting/misaligning Path/Filename/ParamName/Offset/Separator. (new finding) + +- **Severity:** P1 +- **Failure scenario:** case '>' runs `local divide = ::split(str,">")` on str which still starts with the '>' dispatch character, and the rest of the block (lines ~1255-1334) indexes divide[1]..divide[5] per the inline comment's nominal numbering '0>1Path>2Filename>3ParamerName>4Offset>5Separator' — a scheme that only works if divide[0] is the empty string before the first '>'. Because split() drops empty tokens, that leading empty segment (and any interior empty segment, e.g. an intentionally omitted Offset field shown in the code's own example ">strings/testfile.txt>MyKey>>seperator") is silently removed, shifting every subsequent index down by one or more. The `divide.len()==3` objnames/objdescs branch (line 1255) becomes unreachable for the 2-field case it's meant to catch, and the general Path/Filename/ParamName/Offset extraction reads the wrong fields (or wrong number of fields) for the exact usage patterns documented in the surrounding comments. This is the same split()-drops-empty-tokens defect already tracked as T-20 for the ']' operator, recurring here in a different operator; T-67/T-72 (already tracked) describe adjacent but distinct problems in this same case block (unused FindFileInPath result, no path caching) and do not cover this indexing defect. +- **Verification:** Confirmed by direct reading. At line 1251, `str` still carries its leading dispatch character '>' (unlike e.g. case '&' at 1098 or '$' at 1150 which explicitly `str.slice(kRemoveFirstChar)` first) — case '>' never slices, so `::split(str,">")` runs with the leading '>' still present. Per the repo's own documented split() semantics (confirmed by the analogous, already-tracked T-20 bug for ']' at lines 1122-1132, which uses the identical "index shifted because split drops the leading empty token" pattern with an explicit `// [0]=""[ ` comment), the leading empty segment before the first '>' is dropped, shifting every real field down by one index. This directly breaks the `divide.len()==3` branch at line 1255: for the intended 2-field "Object>objnames" input, split() yields only 2 elements (not 3), making that branch unreachable, while a legitimate 3-field Path/Filename/Key input (e.g. the comment's own ">strings/book>/Green.str>MyKey" example) collapses to exactly 3 elements after the empty-drop and gets wrongly routed into the objnames/objdescs branch instead, printing the "wrong format" error. The general-case indexing at lines 1265-1334 (divide[2]/divide[3]/divide[4]/divide[5]) is likewise offset by one from what the inline comment's numbering scheme (line 1247) assumes. This is a distinct defect from T-67/T-72 (which concern the unused FindFileInPath result and missing path caching, both further down at lines 1286-1295) and is not listed elsewhere in docs/OPEN_TASKS.md, so it is correctly characterized as new. + +### Line 1287 — '>' operator's file-existence check result is computed and printed but never used to gate the subsequent dblob.open call. (tracked: T-67) + +- **Severity:** P3 +- **Failure scenario:** Already tracked as T-67. `if (::Engine.FindFileInPath("install_path", divide[2], sref)) { print("yes in "+sref) } else { print("nope try again") }` — either branch just prints; `sref` (which FindFileInPath may have left unset/stale on failure) is passed to `::dblob.open(sref)` regardless of whether the file was actually found, so a missing file is opened with an unvalidated/garbage path instead of failing gracefully. +- **Verification:** Read lines 1284-1313 of "DScript Core.nut". Line 1286 initializes `local sref = ::string()` (empty), line 1287 calls `::Engine.FindFileInPath("install_path", divide[2], sref)` inside an if/else that only prints "yes in "+sref or "nope try again" (lines 1288-1295) — the boolean return value is not stored, not returned, and no `return`/`break` occurs in the else branch. Execution falls straight through to line 1313, `local ofile = ::dblob.open( sref )`, which runs unconditionally regardless of whether the file was found. This exactly matches the claimed defect and the failure scenario (missing file still gets opened via dblob.open with whatever sref holds). It also matches OPEN_TASKS.md T-67 verbatim: "P3 | DScript Core.nut:1286-1295 | `>` file operator: Engine.FindFileInPath result is printed... but never used — dblob.open(sref) runs with an unvalidated path" — same lines, same mechanism, same severity P3. The finding is a real, verified bug and correctly cross-references T-67. + +### Line 1344 — '^%anchor%name' branch reads an out-of-scope variable div_str instead of the just-declared str2. (tracked: T-11) + +- **Severity:** P1 +- **Failure scenario:** Already tracked as T-11. `local str2 = ::split(str,"%")` then `str = div_str[1]` — div_str was declared far earlier in a different switch case ('[' handling at line 1053) and is not the intended value here, so the anchor-relative '^%X%Name' closest-object lookup uses stale/wrong data instead of str2[1]. +- **Verification:** Confirmed real. At DScript Core.nut:1341-1344, inside case '^' 's %-anchor sub-branch, `local str2 = ::split(str,\"%\")` is declared at 1342 and used correctly at 1343 (`anchor = DCheckString(str2[1])`), but line 1344 does `str = div_str[1]` instead of `str2[1]` (or [2], per intended semantics). No `div_str` local exists anywhere in this switch/case or enclosing DGetParam-parsing function — the only similarly-named variable is the unrelated `divide` array used in a completely different case block (the '>' file-operator branch, lines ~1258-1334) which is out of scope here. This is not a truthiness/precedence misreading; it's a genuine undeclared-variable reference that will throw a Squirrel runtime error ("the index 'div_str' does not exist") the first time `^%anchor%name` syntax is parsed, making that feature completely non-functional. This exactly matches T-11 in docs/OPEN_TASKS.md (line 55: same file:line 1344, same description 'locals are str2, code reads div_str[1] (out of scope)', same suggested fix direction). + +### Line 1379 — '?' random-pick operator can index one past the end of the array because Data.RandInt's upper bound is inclusive. (tracked: T-24) + +- **Severity:** P2 +- **Failure scenario:** Already tracked as T-24. `objset[Data.RandInt(0, objset.len())]` — RandInt(0, n) can return n, which is out of range for a 0-indexed array of length n, so the '?' random-object filter can throw an index error on the highest possible roll. +- **Verification:** Confirmed real. Lines 1377-1379 in "DScript Core.nut" show the '?' case builds objset via DCheckString(...) then does `objset[Data.RandInt(0, objset.len())]` with no bounds adjustment or empty-set guard anywhere in the block. Since Data.RandInt's upper bound is inclusive (documented in CLAUDE.md itself), RandInt(0, n) can return n, which is out of range for a 0-indexed array of length n — a genuine off-by-one that will throw on the highest roll. This exactly matches T-24 in docs/OPEN_TASKS.md (same file, same line 1379, same cause, same suggested fix `objset.len() - 1` plus an empty-set guard, same P2 priority), so the tracked-issue correlation is also accurate. + +### Line 1408 — The '{' distance/box filter builds its box-limit array with array(2, array(3)), so both limit slots alias the same inner array and the second write silently overwrites the first. (new finding) + +- **Severity:** P1 +- **Failure scenario:** `local boxlimit = ::array(2, ::array(3))` fills both of boxlimit's two slots with the SAME array reference (Squirrel's array(size, fill) does not clone container fill values — the exact 'shared table/array' pitfall called out for class member defaults, here hitting a plain array() call instead). The subsequent per-axis loop does `boxlimit[0][i] = ancpos[i]-v` then `boxlimit[1][i] = ancpos[i]+v` (lines ~1417-1418); since boxlimit[0] and boxlimit[1] are the same object, the second write clobbers the first, so after setup boxlimit[0][i] == boxlimit[1][i] == ancpos[i]+v for every axis. In the filter's box check, 'exclude if inside box' (values[2] true) becomes `objpos[i] > X && objpos[i] < X`, which can never be true (so nothing is ever excluded), while the default 'keep only inside box' branch becomes `objpos[i] < X || objpos[i] > X`, which is true for virtually every real-valued position (so almost every object is removed). The box-bounds half of the '{' distance filter is silently non-functional in both of its modes. +- **Verification:** Verified by reading DScript Core.nut:1391-1449. Line 1408 does `local boxlimit = ::array(2, ::array(3))`. In Squirrel, array(size, fill) copies the fill value's reference into every slot for reference types (same "shared container" semantics documented elsewhere in this repo's CLAUDE.md for class-member defaults); it does not clone the fill array per slot. So boxlimit[0] and boxlimit[1] are literally the same array object. The loop at lines 1415-1421 then writes `boxlimit[0][i] = ancpos[i]-v` followed by `boxlimit[1][i] = ancpos[i]+v` into that single shared array, so the second write clobbers the first for every axis, leaving boxlimit[0][i] == boxlimit[1][i] == ancpos[i]+v. In the filter closure (lines 1439-1449), the values[2]==true branch becomes `objpos[i] > X && objpos[i] < X` (never true, never excludes), and the else branch becomes `objpos[i] < X || objpos[i] > X` (true for virtually all real positions, excluding almost everything) — exactly as the finding describes. No later code reassigns boxlimit[0] or boxlimit[1] to distinct arrays, and grep of docs/OPEN_TASKS.md finds no existing entry for this location/behavior, so it is not a previously tracked issue. This is a genuine, confirmed defect at the cited lines. + +### Line 1461 — The '' vector-literal operator indexes one past the end of its own split() result. (new finding) + +- **Severity:** P1 +- **Failure scenario:** case '<' does `local ar = ::split(str, "<,")` on str which still has its leading '<' (the dispatch char was never sliced off). Squirrel's split() drops empty tokens, so the leading '<' produces an empty first token that is dropped; for input like "<1,2,3>" the result is only 3 elements (ar[0]="1", ar[1]="2", ar[2]="3>"), yet the code reads ar[1], ar[2], ar[3] — ar[3] does not exist. Any Design Note parameter using the vector syntax throws an index-out-of-range error; the operator is effectively dead. +- **Verification:** Confirmed by reading DScript Core.nut:1003-1488. DCheckString dispatches on `str[kGetFirstChar]` (line 1033) without slicing the leading char off `str` for most cases; case '<' (lines 1459-1461) is entered with str still containing the leading '<' (e.g. "<1,2,3>"), and unlike sibling cases (e.g. '^' at 1339, '/' at 1218) it never does `str = str.slice(1)`. `::split(str, "<,")` splits on both '<' and ',' as delimiter chars; per the documented split() semantics (confirmed in CLAUDE.md and consistent with the '&' Paragraph-sign case at 1180 and '+' case at 1356-1357 which do account for a leading empty/marker token), the leading '<' produces an empty leading token that is dropped, yielding only 3 elements: ar[0]="1", ar[1]="2", ar[2]="3>" for input "<1,2,3>". Line 1461 then reads ar[1].tofloat(), ar[2].tofloat(), ar[3].tofloat() — ar[3] is out of bounds (valid indices 0-2), which Squirrel raises as an array index-out-of-range runtime error. No guard, early return, or prior slice exists between the outer switch head (1033) and this case that would prevent this. The claim is accurate and matches the actual code exactly. + +### Line 1473 — '->%anchor%Prop:Field' branch has the identical out-of-scope div_str bug as the '^' operator. (tracked: T-12) + +- **Severity:** P1 +- **Failure scenario:** Already tracked as T-12. Same shape as T-11: `local str2 = ::split(str,"%")` then `str = div_str[1]` uses the wrong, out-of-scope variable, so a property-get with an explicit '%anchor%' prefix reads garbage instead of str2[1]. +- **Verification:** Confirmed by direct code reading. At DScript Core.nut:1471, `local str2 = ::split(str,"%")` computes the split, and line 1472 correctly uses `str2[1]` for the anchor. But line 1473 does `str = div_str[1]` — `div_str` is a different local, declared far earlier at line 1053 inside the unrelated `case '['` block of the same switch statement (no braces separate the cases, so it's lexically the same block-scope, but that declaration only executes if `str[kGetFirstChar]=='['`). Since we're in `case '-'` (str starts with `-`), execution jumps straight to this case and line 1053 never ran, so `div_str` is unassigned garbage/null rather than the freshly split `str2`. This is byte-for-byte the same bug shape as T-11 at line 1344 (`case '^'`), which also declares `str2` via split and then erroneously reads `div_str[1]` instead of `str2[...]`. The finding accurately describes the code, the failure scenario (garbage read on a `->%anchor%Prop:Field` parameter) is real and not preempted by any guard, and it matches T-12 in docs/OPEN_TASKS.md verbatim ("Same bug in the `->%anchor%Prop:Field` branch", same fix suggestion "as above" pointing to str2 usage). Not refuted. + +### Line 1539 — DPrint's Debug-flag lookup uses GetClassName() instead of the documented _script convention, so per-Copies and DTrigger-side Debug toggles don't work. (new finding) + +- **Severity:** P2 +- **Failure scenario:** `mode = DGetParamRaw(GetClassName()+"Debug", false)` always reads the base class name's Debug flag, never `_script` (which becomes e.g. 'DStdButton2' under Copies or gets a 'T' suffix while DTrigger is in trigger mode, per CLAUDE.md's explicit '_script + "Foo", never a hard-coded string' rule). A mission author setting 'DStdButton2Debug=1' in a Design Note to debug only the second Copies-instance gets no output (the lookup checks 'DStdButtonDebug' instead); conversely setting 'DStdButtonDebug=1' turns on debug printing for every copy on that object at once. The same disconnect applies to DTrigger's separate trigger-side parameter namespace. +- **Verification:** Confirmed real for the Copies scenario. DPrint (DBasics, "DScript Core.nut":1535-1567) is inherited unmodified by DBaseTrap/DStdButton/etc.; line 1539 keys the Debug lookup off `GetClassName()`, which is the compiled class name and is never suffixed when Copies is active (Copies only mutates the instance's `_script`, see lines 1624-1633: `_script = GetClassName() + (current+1).tochar()`). So for a 2nd Copy of DStdButton, `_script` is "DStdButton2" but `GetClassName()+"Debug"` still checks "DStdButtonDebug" — exactly the failure described: `DStdButton2Debug=1` is invisible to DPrint, and `DStdButtonDebug=1` fires for every copy at once. Note the finding overstates the DTrigger half: DTrigger overrides `GetClassName()` itself (lines 2152-2157) to append "T" while `_TModus` is set, so plain trigger-side Debug flags (without Copies) actually do line up with `_script`'s "T" suffix; only the combination of Copies+DTrigger would still break. That inaccuracy is minor next to the fully verified, code-confirmed Copies defect, and the pattern `("_script" in instance)? instance._script : instance.GetClassName()` used elsewhere (e.g. lines 2340, 2373, 2427) shows the framework already has, and simply failed to apply here, the correct fallback idiom. Not tracked in docs/OPEN_TASKS.md (T-60 covers unconditional prints in DCheckString, unrelated). Overall a genuine, reproducible P2 defect, just with an overstated DTrigger clause. + +## Cleanup items (3) + +- **Line 993:** Dead, fully commented-out alternate _get() delegate implementation left at the top of the class with a 'performance wise nahh' rationale comment. +- **Line 1025:** Dead, fully commented-out empty-string handling block (the one guard that would have prevented the empty-string crash in DCheckString) — either restore it live or remove it. +- **Line 1148:** Near-duplicated QVar-substitution logic ('find a second $/§, DivideAtNext, substitute difficulty or kReplaceQVarOperatorWith') repeated almost verbatim between the '$' and '§/paragraph' cases. + +## Incomplete items (4) + +- **Line 1049:** '[null]' sugar parameter is explicitly unhandled with an author TODO about error checking. — Comment reads '// Don't handle this parameter. TODO: Check for errors.' — behavior for [null] is a passthrough with no validation. +- **Line 1050:** '[item]'/'[weapon]' sugar parameters are marked with the author's own '#TODO correct?' — the DarkUI/ShockGame branching is unverified. — Uses (::GetDarkGame() != 1) ? DarkUI.InvItem()/InvWeapon() : ShockGame.GetSelectedObj()/PlayerGun() but the author flags the mapping itself as unconfirmed. +- **Line 1287:** '>' file operator has no path caching or FM-relative resolution, per the author's own TODO. — Already tracked as T-72 in docs/OPEN_TASKS.md; every '>' lookup re-resolves install_path via Engine.FindFileInPath with no cache and no FM-specific search. +- **Line 1361:** '+'/'+-' set operators leave duplicate-removal and an O(n·m) removal pass as an explicit unimplemented .map() TODO. — Already tracked as T-27 (status ⊘ won't-fix — a deliberate, documented decision to leave '+' duplicates alone), but the '.map(function(obj){...})' TODO for '+-' at line ~1370 is still literally unimplemented dead-end commentary. + +## Suggestions (4) + +- **Guard DCheckString's operator switch with an explicit early-return for str=="" (reviving the intent of the dead commented-out block at line 1025) instead of relying on falling through to str[kGetFirstChar].** — Prevents the out-of-range crash on empty-string parameters and gives an intentional, documented return value instead of an engine exception. +- **Replace `::array(2, ::array(3))`-style container fills with an explicit loop or two literal array() calls anywhere a per-slot-independent array is needed.** — array(size, fill) shares one reference across all slots for container fill values in Squirrel; this is the same footgun already called out for class member defaults, and it is easy to reintroduce elsewhere in the codebase. +- **Collapse the pattern `_FormatForReturn(DCheckString(x, returnInArray), returnInArray)` used throughout DCheckString to a single call, since DCheckString already applies _FormatForReturn internally before returning.** — DCheckString is documented as the hottest function in the framework (see T-60); the outer re-wrap is redundant work on every single parameter evaluation, and it's also the exact spot where the returnInArray argument gets dropped by copy-paste in three places. +- **Factor the repeated 'find a second delimiter, DivideAtNext, substitute difficulty/kReplaceQVarOperatorWith' logic shared by the '$' and '§' cases into one helper.** — Reduces duplication and means a future fix (or the difficulty-substitution behavior itself) only needs to be corrected in one place. diff --git a/docs/review/wave1/dhub.md b/docs/review/wave1/dhub.md new file mode 100644 index 0000000..cad9391 --- /dev/null +++ b/docs/review/wave1/dhub.md @@ -0,0 +1,77 @@ +# DHub + +**File:** `DScript Core.nut` · **Anchor:** line 2538 + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +DHub is meant to be 'multiple DRelayTraps in one object' — a per-message dispatcher extending DRelayTrap. The file header already says it should not work at 0.81, and reading the whole class (2538-2732) confirms this many times over, well beyond the two typos already tracked as T-13/T-14. Its constructor can crash on the very Design Note syntax shown in its own docstring (odd/empty split() tokens), it never calls base.constructor() (silently dropping the DBaseTrap-wide Count/Capacitor bootstrap and per-message On/OffCapacitor tracking), it never invokes RepeatForCopies (so the universal Copies parameter is a silent no-op on DHub), its DGetParamRaw fallback skips a whole tier of the documented per-message-default mechanism for numbered relay copies, and _script state leaks across calls in OnBeginScript. This substantially expands what T-40 currently documents. + +## Confirmed bugs (7) + +### Line 2589 — DHub.constructor() never calls base.constructor(), dropping ConstructParameters() and per-message On/OffCapacitor init (new finding) + +- **Severity:** P2 +- **Failure scenario:** Because base.constructor() (which normally runs ConstructParameters() and, in the editor, bootstraps the DScriptHandler marker object per DBasics.constructor at 2204) is never invoked, and the per-entry setup here only SetData/ClearData's `entry+"Counter"`/`entry+"Capacitor"` (never `entry+"OnCapacitor"`/`entry+"OffCapacitor"`), any DHub message entry that sets an OnCapacitor/OffCapacitor parameter never gets its `IsDataSet(_script+"OnCapacitor")` flag set, so DCheckParameters (1937-1938) never triggers the capacitor-check for that entry — the feature is silently inert for DHub. +- **Verification:** Confirmed by direct reading. DHub.constructor() at DScript Core.nut:2589-2633 never calls base.constructor(), so DBaseTrap's ConstructParameters() (Core:1606-1615, which sets _script+\"OnCapacitor\"/_script+\"OffCapacitor\" data slots at lines 1612-1613) never runs for DHub. DHub's own per-entry replication loop (lines 2616-2627) only SetData/ClearData's entry+\"Counter\" and entry+\"Capacitor\" — there is no equivalent line for entry+\"OnCapacitor\"/entry+\"OffCapacitor\" anywhere in the file (grep confirms the only writers of those two keys are lines 1612-1613, unreachable for DHub). At runtime, DHub.OnMessage() (line 2714) sets _script = classname+msg (i.e., the per-entry name) before calling DBaseFunction, so DCheckParameters's checks at lines 1937-1938 (`IsDataSet(_script+\"OnCapacitor\")` / `IsDataSet(_script+\"OffCapacitor\")`) are evaluated against exactly the entry-specific key that was never set — meaning those checks are always false and the On/OffCapacitor feature is silently inert for any DHub message entry. This is a real, precisely-locatable defect, not a misreading; OPEN_TASKS.md's T-40 only says "DHub is non-functional, re-review the whole class" without this specific On/OffCapacitor detail, so it is reasonable to treat it as a distinct, previously untracked specific finding. + +### Line 2598 — !StringDN.find("=") wrongly treats a value starting with '=' as having no '=' present (new finding) + +- **Severity:** P3 +- **Failure scenario:** find() returns 0 (falsy) when '=' is the very first character of StringDN, so `!StringDN.find("=")` evaluates true and the entry is skipped via `continue` even though an '=' operator is present — same 0-vs-null gotcha as T-23, applied to a different site not currently tracked. Narrow edge case (a DN substring literally starting with '='), but a real instance of the pattern the review brief calls out. +- **Verification:** Line 2598 of "DScript Core.nut" reads: `if (typeof StringDN != "string" || !StringDN.find("=")) // no string or no = present, skip`. Squirrel's `string.find()` returns the numeric index of the first match or `null` if absent, and per Squirrel truthiness `0` is falsy (distinct from `null`, per CLAUDE.md's own documented gotcha "find() returns null when absent but 0 is a valid index... if (!x) is a bug"). If `StringDN` (the DN value for a DHub sub-entry, e.g. "=Foo;Bar=Baz") has its first character as '=', `find("=")` returns `0`, `!0` evaluates true, and the `continue` at line 2599 fires, silently discarding a valid sub-DN entry that does contain '='. Nothing before this line (the `startswith(entry, _script)` check at 2595, or anything upstream in the constructor at 2589-2593) guards against a value string beginning with '=', so the failure scenario as described genuinely occurs given the full method body. I confirmed via `docs/OPEN_TASKS.md` that T-23 covers the same 0-vs-null pattern only at lines 453/485, and no existing task (including T-40's DHub notes) references line 2598, so the "new, not previously tracked" claim also holds. The scenario is admittedly narrow (requires a DN value literally starting with '='), consistent with the claimed P3 severity rather than something more severe. + +### Line 2600 — Constructor's DN-string split into key/value pairs crashes or silently misaligns on odd token counts / empty fields (new finding) + +- **Severity:** P1 +- **Failure scenario:** `::split(StringDN, "=;")` drops empty tokens (documented repo gotcha). The class's own docstring example `"TOn=RelayMessage;TDest=DestinationObject;Delay"` (bare trailing `Delay`, no `=value`) yields a 5-element array; the `for(i+=2)` loop's last iteration reads `ar[i+1]` == `ar[5]`, out of bounds, crashing construction. A value containing an empty field like `"Delay=;Repeat=3"` instead silently shifts every subsequent key/value pair by one, corrupting all later parsed parameters for that message with no error at all. +- **Verification:** Confirmed by direct reading of DScript Core.nut:2589-2633. Line 2598 only guards `!StringDN.find("=")` (skip if no "=" at all) but does not check that the split result has an even element count. Line 2600's `::split(StringDN, "=;")` drops empty tokens (the documented repo-wide gotcha, and the identical failure mode already tracked separately as T-20 for the `]` operator). Applying it to the class's own docstring example `"TOn=RelayMessage;TDest=DestinationObject;Delay"` yields the 5-element array `["TOn","RelayMessage","TDest","DestinationObject","Delay"]`; the loop at 2601-2603 (`i+=2`) reaches `i=4`, reads `ar[4]="Delay"` then `ar[5]` for `val` — index 5 doesn't exist in a 5-element Squirrel array, which throws a runtime "index out of range" error, aborting construction. Likewise an empty-valued field (e.g. `"Delay=;Repeat=3"`) drops the empty token, shifting all subsequent key/value pairings and eventually hitting the same out-of-bounds read. This is a real, distinct defect: T-40 in docs/OPEN_TASKS.md only generically flags DHub as broken pending a re-review and cites different causes (T-13/T-14/DGetParamRaw name-mangling); it does not mention this specific split/index-parity bug at 2600, so the claim's "not previously tracked" is accurate. + +### Line 2636 — DHub never calls RepeatForCopies, so the universal Copies parameter has no effect on DHub objects (new finding) + +- **Severity:** P2 +- **Failure scenario:** OnBeginScript/OnMessage/OnTimer all fully override the base versions without ever invoking RepeatForCopies (contrast with base DBaseTrap.OnBeginScript at 1667 and OnTimer at 1783). Setting `Copies=2` on a DHub object — a parameter every other DBaseTrap-derived script honors — is silently ignored; the `DHub2*` design-note keys are never read. +- **Verification:** Read the full DHub class body (DScript Core.nut:2538-2728, confirmed by grep as the exact class span before DTrapSetQVar starts at 2732). RepeatForCopies is defined once on DBaseTrap (1619) and overridden on DTrigger (2159); a repo-wide grep for RepeatForCopies confirms zero occurrences anywhere inside the DHub class body. DHub's constructor (2589) does not call base.constructor()/ConstructParameters either, so it never even indirectly triggers RepeatForCopies. OnBeginScript (2636-2655), OnTimer (2657-2666) and OnMessage (2678-2722) are all complete rewrites that never call RepeatForCopies, unlike the base DBaseTrap versions at 1649-1669 and 1780-1783 which explicitly gate re-entry through `if (RepeatForCopies(::callee())) …`. The `curCopy` loop inside DHub.OnMessage (2709-2721) is a different, DHub-internal mechanism keyed on `classname+msg+N` (multiple parallel message entries), not the universal `ClassName+"Copies"` DGetParam check that RepeatForCopies uses (GetClassName()+"Copies", 1624) — so it does not substitute for the missing call. Since `_script` in DHub is only ever set to `GetClassName()` or `classname+msg`, never `GetClassName()+"2"`, the DGetParamRaw fallback logic at 2572-2576 (which exists to support Copies name-mangling) is dead code for this path, confirming `DHub2*` keys are indeed never consulted. docs/OPEN_TASKS.md's T-40 flags DHub as broken generically and tells reviewers to "re-review the whole class" but does not itself call out this specific missing-RepeatForCopies defect, so treating it as a distinct, newly-surfaced finding is reasonable. This is a concrete, line-verifiable defect, not a misreading. + +### Line 2645 — DGetParam(_entry + "Delay") uses an undefined variable `_entry`; the foreach loop variable is `entry` (tracked: T-13) + +- **Severity:** P1 +- **Failure scenario:** Any time OnBeginScript() finds a sub-entry with per-frame InfRepeat data starting with 'F' (i.e. a per-frame-delayed repeat is being re-registered after save/load), evaluating `_entry + "Delay"` throws because `_entry` is neither a local, member, nor global — killing OnBeginScript for that object. +- **Verification:** Confirmed by reading DScript Core.nut:2636-2655. OnBeginScript's foreach loop is declared as `foreach(entry, val in userparams())` (line 2638), so `entry` is the only loop variable in scope; a repo-wide grep shows `_entry` never appears anywhere else in the file as a member, local, or global. When the stored InfRepeat data for a sub-entry starts with 'F' (line 2644), execution reaches line 2645's `DGetParam(_entry + "Delay")`, and Squirrel's identifier resolution (local/upvalue/member-via-delegation/global) will fail to find `_entry`, throwing a runtime error and aborting the rest of OnBeginScript for that object — exactly the claimed failure scenario. No surrounding guard neutralizes this (the 'F' check at line 2644 is precisely the precondition needed to hit the bad line), and the parameter-naming convention is indeed violated (should be `entry + "Delay"`, consistent with how `entry` is used elsewhere in the same function, e.g. line 2639/2643). This exactly matches T-13 in docs/OPEN_TASKS.md ("DGetParam(_entry + \"Delay\") — loop variable is entry", P1, fix `_entry` → `entry`), so the claim is accurate and not refuted. + +### Line 2671 — The ["On"+kResetCountMsg] handler references undefined `val` where the foreach binds `(k, v)` (tracked: T-14) + +- **Severity:** P1 +- **Failure scenario:** Sending the ResetCount message to a DHub object with any Design Note parameters throws on the first loop iteration since `val` is not a local/member/global in that scope (unlike the superficially similar OnBeginScript loop at 2638-2639, where `val` genuinely is the bound foreach value and is NOT broken) — note the existing T-14 entry bundles both sites together though only this one (OnResetCount, 2671) is actually defective. +- **Verification:** Confirmed by direct read of DScript Core.nut:2668-2676: the ["On"+kResetCountMsg] handler's foreach is `foreach (k, v in userparams())` (line 2670), yet the body at line 2671 tests `if (!val && IsDataSet(k + "Counter"))` — `val` is never bound here (the pair variable is `v`), is not a class member of DHub (only DefOn/DefOff/DHubParameters exist per lines 2562-2566), and no global `val` exists anywhere in the repo. Squirrel resolves free identifiers via locals/closures/`this`-members/root table, so this will throw "the index 'val' does not exist" on the very first iteration whenever the object has at least one userparam, i.e. essentially always. By contrast, the superficially similar loop at OnBeginScript (lines 2638-2639) genuinely uses `foreach(entry, val in userparams())`, so `val` is legitimately bound there — confirming the claim that only the OnResetCount site (2671) is broken. This exactly matches T-14 in docs/OPEN_TASKS.md, which lists both 2639 and 2671 together under "`if (!val && …)` — DHub.OnBeginScript / OnResetCount iterate foreach (k, v …); val undefined" — though as the finding notes, 2639's own loop variable actually is named val (not k,v), so the OPEN_TASKS wording is imprecise about site 2639 but the 2671 defect it flags is real and P1-appropriate (throws at runtime, kills the ResetCount feature entirely for DHub). + +### Line 2699 — DHubParameters.find(k) < 0 compares a possibly-null find() result with an integer via `<` instead of `== null` (new finding) + +- **Severity:** P2 +- **Failure scenario:** The author's own comment ('this is true for not found null < 0') acknowledges relying on unverified null-vs-integer ordering semantics in Squirrel's relational operator rather than the safe `== null` pattern used correctly elsewhere (ObjectsInPath, T-23). If that ordering assumption is wrong, the general 'StopRepeat with no target' branch either throws or silently never (or always) matches real message-entry keys, so global stop-repeat cleanup can silently fail to run for any DHub object. +- **Verification:** Confirmed. DHubParameters (Core:2566) is a plain array literal; its native .find() returns null when the key isn't present (CLAUDE.md itself documents this Squirrel semantic and cites two other real bugs from the same mistake, T-23). At Core:2699, `DHubParameters.find(k) < 0` uses the relational `<` operator, which in Squirrel's ObjCmp only succeeds for same-type or both-numeric operands (or via a `_cmp` metamethod on tables/instances/userdata); null vs. integer satisfies none of those and falls through to Raise_CompareError, i.e. a runtime type-comparison error, not the boolean the inline comment assumes. This is different from `==`/`!=` (used correctly elsewhere, e.g. T-21/T-22), which trivially return not-equal across types without erroring — so the author's comment conflates equality semantics with relational-operator semantics. Walking the enclosing `else` branch (Core:2696-2706, the "StopRepeat with no target" path): for any userparams() key `k` that starts with the classname but is one of the many custom sub-script/message names (the normal, intended case — e.g. "DHubMyMessageOn"), `find(k)` returns null and the comparison throws, aborting the rest of OnMessage for that call (including the unrelated "SetUp" dispatch below at 2709-2721), so global stop-repeat cleanup fails exactly as described. This root cause is distinct from the already-tracked DHub issues (T-13/T-14/T-40 concern `val` vs `v` in OnBeginScript/OnResetCount, not this comparison), so it is correctly flagged as a new, previously untracked defect. + +## Cleanup items (2) + +- **Line 2632:** Commented-out debug call `// DumpTable(userparams())` left in the constructor +- **Line 2724:** Fully commented-out DoOn(DN) override stub that only calls base.DoOn(DN) — dead code left behind from development/verification + +## Incomplete items (1) + +- **Line 2563:** DefOff field declared with comment 'could be specified' but never read or written anywhere in the class — DefOn is actively used as the per-instance 'default On message' (set at line 2713 and consulted via DGetParamRaw's this-as-DN trick). The mirrored DefOff mechanism implied by the field and its comment was never wired up anywhere in OnMessage/DBaseFunction. + +## Suggestions (5) + +- **Replace DHubParameters.find(k) < 0 and !StringDN.find("=") with explicit `== null` checks** — Matches the fix pattern the codebase already uses correctly in ObjectsInPath (T-23) and removes reliance on Squirrel's cross-type relational-operator behavior, which is not documented/guaranteed here. +- **Validate ar.len() is even (and non-empty pairs) after split() in the constructor before indexing ar[i+1]** — Prevents an out-of-bounds crash or silent key/value misalignment on any Design Note value with an odd token count or an empty field — including the class's own docstring example. +- **Restore _script = GetClassName() at the end of OnBeginScript's per-entry loop** — Mirrors the restore pattern already used in FrameUpdate() and avoids leaking stale sub-entry state into later per-instance logic (T-91). +- **Call base.constructor() in DHub's constructor, or explicitly document why it's intentionally skipped** — Keeps DHub consistent with every other DBaseTrap subclass's Count/Capacitor/DScriptHandler-bootstrap conventions instead of silently diverging from them. +- **Given the file header already disclaims DHub as non-functional, consider gating the class (e.g. behind a clear DEBUG/experimental flag or compile exclusion) until T-13/T-14 and the additional issues above are fixed** — Prevents an FM author from picking DHub up in its current state and hitting a construction-time crash from following the class's own documented example. + +## Candidate findings rejected on verification (2) + +_Kept here for transparency — reviewed and adversarially checked, but not confirmed as real defects._ + +- **Line 2572:** DGetParamRaw's fallback skips the per-message-default tier for numbered relay copies (curCopy>1) — _refuted:_ The technical mechanism is real: DHub's DGetParamRaw (DScript Core.nut:2568-2578) only checks DN directly, then falls back to GetClassName()+suffix once `par` starts with `_script`. In OnMessage's do/while loop (lines 2709-2721), `_script` for curCopy==1 is `sub_script` (e.g. "DHubTurnOn"), matching the per-message default entries synthesized into DN by the constructor (2593-2632), but for curCopy>=2 `_script` becomes `sub_script+curCopy` (e.g. "DHubTurnOn2"), which is never a DN key by default, so the fallback strips it down straight to the global "DHub"+suffix tier (e.g. "DHubDelay"), skipping "DHubTurnOnDelay" entirely — exactly as claimed. However, the finding is not new: docs/OPEN_TASKS.md:114 (T-40; line shifted from :106 by the 2026-08-04 merge) already states, verbatim in substance, "DGetParamRaw name-mangling that assumes _script is always a prefix of par" as one of the confirmed reasons DHub (DScript Core.nut:2538+) is tracked as non-functional. This submitted finding is a more detailed elaboration of the same already-tracked root cause, not an independently new defect, so it fails the "new issue not previously tracked" criterion. +- **Line 2643:** _script leaks out of OnBeginScript's per-entry loop instead of being restored to the classname — _refuted:_ The described asymmetry is real at the byte level: OnBeginScript (2636-2655) sets `_script = entry` at line 2643 and never resets it to `GetClassName()` before falling into `OnMessage()` at 2654, unlike FrameUpdate (1675/1677) which explicitly sets-then-resets. However, tracing every actual consumer of `_script` shows the leaked value is inert. The two functions called right where `_script` is stale, `::DHandler.PerFrame_ReRegister`/`PerMidFrame_ReRegister` (2373/2381/2386/2427/2434/2444), deliberately *capture* `instance._script` at call time to remember which sub-entry to pass back into `FrameUpdate(whichscript)` later — setting it to `entry` there is required, not a bug. The very next call, `OnMessage()` (2678), never reads the incoming `_script`; it computes `local classname = GetClassName()` fresh and only ever *writes* `_script` unconditionally in each branch it takes (2684, 2691, 2714, 2719), and `OnTimer` (2657-2666) and the ResetCount handler behave the same way — none of them treat `_script` as needing to already equal the classname baseline. So while the code is stylistically inconsistent with the FrameUpdate idiom, the claimed failure ("any subsequent code path assumes _script reflects the class baseline") is speculative — no such code path exists in this class, and DHub's whole design already hand-reassigns `_script` fresh on every real message rather than resting on a baseline (matching the framework-wide T-91 risk already generically tracked, not a newly discovered concrete failure). diff --git a/docs/review/wave1/drelaytrap-dtrigger.md b/docs/review/wave1/drelaytrap-dtrigger.md new file mode 100644 index 0000000..a9c7760 --- /dev/null +++ b/docs/review/wave1/drelaytrap-dtrigger.md @@ -0,0 +1,58 @@ +# DRelayTrap + DTrigger + +**File:** `DScript Core.nut` · **Anchor:** line 2062 + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +DRelayTrap (Core.nut:2062-2146) provides the message/stim relay plumbing (DSendMessage/DMultiMessage/DRelayMessages) used by most of the framework, and DTrigger (Core.nut:2148-2197) layers a parallel "T"-prefixed timing namespace on top via TriggerMessages()/GetClassName()/RepeatForCopies(). The everyday, no-delay, no-Copies path (a script fires, matches a condition, relays TurnOn/TurnOff immediately) works as designed. However, the two headline features this pair adds beyond plain relaying are both broken by static trace: DTrigger's T-mode Delay/CapacitorFalloff/InfRepeat timing never actually resolves because _script's temporary "T" suffix is stripped before the corresponding timer fires and OnTimer's name-match then always misses, and DTrigger.RepeatForCopies always returns true regardless of the base class's real "more copies pending" signal, causing duplicate OnMessage() dispatch when Copies>=2 is combined with DTrigger. Separately, DRelayMessages silently defeats DSendMessage's own PostMessage-by-default behavior for virtually every script in the repo, and a debug code path in DMultiMessage crashes outside the editor if the documented Debug=1 diagnostic flag is ever used on a shipped mission. None of these five bugs are already tracked verbatim in docs/OPEN_TASKS.md, though two are concrete instances of the general risk T-91 already flags. + +## Confirmed bugs (4) + +### Line 2106 — DMultiMessage's debug branch unconditionally calls DTestTrap.DumpTable(), a class defined only in the editor-only DScript_ModdingTools.nut file, so enabling the documented [Script]Debug=1 diagnostic flag on a shipped mission (without that file loaded) throws instead of printing. (new finding) + +- **Severity:** P2 +- **Failure scenario:** CLAUDE.md itself recommends `[ScriptName]Debug=1` as "the fastest way to diagnose 'my trap didn't fire'" and DScript_ModdingTools.nut is documented as editor-only tooling (DSpy, DEditorTrap, DTestTrap, DPerformanceTest) not meant to ship. If a mission author sets Debug=1 on a DRelayTrap-derived object in a build that doesn't include DScript_ModdingTools.nut, DMultiMessage's `if (DPrint()){ ...; DTestTrap.DumpTable(targets); ...; DTestTrap.DumpTable(messages) }` throws "the index 'DTestTrap' does not exist" the moment any message is relayed, instead of producing the intended diagnostic dump — turning the recommended debugging technique into a crash for this specific class. +- **Verification:** Confirmed by reading DScript Core.nut:2101-2117 and the DPrint() implementation at 1535-1567. DMultiMessage's debug branch (line 2104-2109) calls `DPrint()` with no arguments, so `dbgMessage` is null and mode is overridden to `DGetParamRaw(GetClassName()+"Debug", false)` (line 1539); when the documented `[Script]Debug=1` param is set this is truthy, and DPrint() unconditionally `return true`s at line 1565 with no IsEditor() gate on that return path (the IsEditor() check at line 1551 only guards the monolog print inside the `dbgMessage` block, which is skipped here since dbgMessage is null). So `if (DPrint())` at line 2104 is satisfied purely by the Debug param being set, in editor or shipped game alike, and control falls straight into `DTestTrap.DumpTable(targets)` / `DTestTrap.DumpTable(messages)` with no existence check. `DTestTrap` is defined only in DScript_ModdingTools.nut (grep confirms the class there and its extends DEditorScripts, and CLAUDE.md/file-header list it as editor-only tooling not meant to ship). If that file isn't loaded, the bare identifier lookup fails and Squirrel throws "the index 'DTestTrap' does not exist" at the exact moment DMultiMessage runs — i.e. any time a DRelayTrap/DTrigger-derived object relays a message with Debug=1 set, aborting the message relay instead of performing it. This same unguarded pattern also recurs at Core.nut:880 and 2758 and SFX.nut:1435, reinforcing that it's a real, reproducible code path rather than a misreading. grep of docs/OPEN_TASKS.md shows no existing T-nn entry mentioning DTestTrap/DumpTable/DMultiMessage, so it is indeed untracked. The claim holds up on all counts: severity, novelty, and mechanism. + +### Line 2129 — DRelayMessages() calls DGetParam(_script+"PostMessage") with no explicit default, so it evaluates to null (not the true DSendMessage's own signature defaults to), silently flipping every relayed message from PostMessage to SendMessage unless the undocumented PostMessage parameter is set. (new finding) + +- **Severity:** P2 +- **Failure scenario:** For any DRelayTrap/DTrigger-derived script that never sets `[Script]PostMessage` in its design note (essentially all of them, since the parameter isn't documented anywhere in the repo), `DGetParam(_script+"PostMessage")` returns null (DGetParam's own default is null). That null is passed explicitly into DMultiMessage's `post` parameter and then into DSendMessage's `post` parameter, which overrides DSendMessage's own coded default of `true` (since Squirrel only applies a parameter default when the argument is omitted, not when null is passed explicitly). Result: `if (post) return PostMessage(...)` takes the else branch, so every DRelayTrap-relayed message uses immediate SendMessage instead of the deferred PostMessage the function signature clearly intends as the baseline — a silent behavior change across the entire framework's core relay path. +- **Verification:** Confirmed real. At line 2129, `DGetParam(_script + "PostMessage")` is called with no defaultValue argument, so per DGetParam's signature (`DGetParam(par, defaultValue=null, DN=null, returnInArray=false)`, Core:1499) and DCheckString's null passthrough (Core:1014-1017), it evaluates to `null` whenever the undocumented `PostMessage` design-note key isn't set (confirmed via repo-wide grep: this parameter name appears nowhere else, including docs/). That `null` is passed as the third positional argument to `DMultiMessage(targets, messages, post=true, ...)` (Core:2101) and forwarded unchanged to `DSendMessage(obj, msg, post, ...)` (Core:2114), both of which default `post` to `true` only when the argument is *omitted* — Squirrel does not re-apply a default when `null` is explicitly supplied. Inside `DSendMessage`, `if (post) return PostMessage(...) ; return SendMessage(...)` (Core:2082-2084) then takes the SendMessage branch because `null` is falsy in Squirrel (consistent with the codebase's own `if (!DN)` null-checking idiom at Core:2122). So every DRelayTrap/DTrigger-derived relay, absent the undocumented parameter, silently uses immediate SendMessage rather than the PostMessage default the two functions' own signatures encode — matching the claimed scenario exactly, and it is not already listed in docs/OPEN_TASKS.md. + +### Line 2159 — DTrigger.RepeatForCopies always returns true and discards base.RepeatForCopies' real result, so callers that gate on it (OnBeginScript, the OnResetCount handler) re-invoke OnMessage() once per intermediate copy instead of only after the last copy. (tracked: T-91) + +- **Severity:** P2 +- **Failure scenario:** A DTrigger-derived script (e.g. DObjectPanTo, née DFocusOverTime) has `Copies="2"`. OnBeginScript() does `if (RepeatForCopies(::callee())) OnMessage()`. In base.RepeatForCopies (Core.nut:1619-1640) every non-terminal copy level hardcodes `return false` after dispatching to the next copy via `func.acall(vargv)`, and only the terminal copy level returns true — this is exactly what lets only the *last* copy's context call OnMessage(). DTrigger's override (Core.nut:2159-2169) ignores that real value: it calls `base.RepeatForCopies.acall(vargv)` twice (normal pass + a _TModus=true pass) and always ends with `return true`. So the TOP-level (copy #1) invocation of OnBeginScript also sees `true` even though the base call actually returned false (copy #2 is still pending), and calls OnMessage() a second, redundant time. Net effect: OnMessage()/DBaseFunction (and therefore DoOn/DoOff and any relayed messages) fire once per copy instead of exactly once per mission-start/ResetCount event, for any DTrigger subclass combined with Copies>=2. +- **Verification:** Confirmed real. In DBaseTrap.RepeatForCopies (Core.nut:1619-1640), for Copies>=2 the outer/first-instance frame always ends at `return false` (line 1637, after dispatching the next copy via `func.acall(vargv)`), and only the innermost/last-copy frame reaches `return true` (line 1631) — this is exactly the mechanism callers rely on to fire OnMessage() only once, after the final copy. DTrigger.RepeatForCopies (2159-2169) calls `base.RepeatForCopies.acall(vargv)` twice (2162, 2166, the second under `_TModus=true`) as bare statements with no capture of the return value, then unconditionally executes `return true` (2168-2169) regardless of what either base call actually returned. Since RepeatForCopies is virtually dispatched (GetClassName()/_script are read via `this` inside the base method too), every caller that gates on it — DBaseTrap.OnBeginScript's `if (RepeatForCopies(::callee())) OnMessage()` (1667-1668) and the `["On"+kResetCountMsg]` handler's identical pattern (1685-1686) — sees `true` both at the top-level/copy-1 frame and again at the recursively-invoked last-copy frame, so OnMessage()/DBaseFunction fires once per copy instead of once total. I traced this concretely with Copies="2": the outer call's real base result is discarded and DTrigger still returns true, causing a duplicate OnMessage() invocation at the copy-1 level in addition to the correct one at copy-2. Regarding T-91: that tracked issue is about a different mechanism (_script mutation/restoration corrupting later parameter lookups if a caller forgets to restore it), not about the discarded boolean/double-dispatch defect described here, so the "matches T-91" linkage is loose — same function, distinct symptom — but that doesn't change that the core defect itself is real and precisely locatable at lines 2162/2166/2168-2169. + +### Line 2180 — DTrigger's T-mode timing (TDelay, TCapacitor/TCapacitorFalloff, per-frame TInfRepeat) is scheduled while _script carries a temporary "T" suffix, but TriggerMessages() strips the suffix back off before the corresponding timer/registration ever fires, so the later name-match in OnTimer()/OnBeginScript() never succeeds. (tracked: T-91) + +- **Severity:** P1 +- **Failure scenario:** A DHitScanTrap (extends DTrigger) has design-note `DHitScanTrapTDelay="2"`. TriggerMessages() sets `_script = _script + "T"`, then DCheckParameters (Core.nut:1969-2025, inherited unmodified from DBaseTrap) schedules a timer via `DSetTimerData(_script+"Delayed", ...)` i.e. name "DHitScanTrapTDelayed", and TriggerMessages immediately slices the "T" back off `_script` and returns. Two seconds later OnTimer() (Core.nut:1744) fires with `bmsg.name=="DHitScanTrapTDelayed"`, but the check is `TimerName == _script + "Delayed"` and `_script` is now the plain "DHitScanTrap" again, so the comparison is always false — DoOn/DoOff is never called, any Repeat rescheduling never happens, and (via the analogous CapacitorFalloff timer at Core.nut:1765-1780, and the InfRepeat re-registration check in OnBeginScript at Core.nut:1653-1665) TCapacitorFalloff and TDelay="NF" infinite-repeat behavior are silently dropped forever. Only the zero-delay "Stage 5" immediate path works for T-mode, defeating the class's advertised "independent trigger-side timing" for anything that actually delays. +- **Verification:** Confirmed by tracing the exact call chain. TriggerMessages (Core.nut:2179-2195) sets `_script = _script + "T"` at line 2180 before calling `DCheckParameters(DN, ScriptAction)` at line 2182. DCheckParameters (inherited unmodified from DBaseTrap, Core.nut:1921-2033) schedules a delay via `SetData(_script+"DelayTimer", DSetTimerData(_script+"Delayed", delay, ...))` at line 2025, using the T-suffixed `_script` (e.g. "DHitScanTrapTDelayed") as the literal timer name — DSetTimerData/SetOneShotTimer (line 1522-1524) do no further name transformation. TriggerMessages then synchronously restores `_script = _script.slice(0,-1)` (lines 2184/2189/2193) and returns, all before the timer ever fires. When the timer later fires, OnTimer (Core.nut:1739, not overridden by DTrigger or DHitScanTrap) checks `TimerName == _script + "Delayed"` at line 1744, but by then `_script` is back to the plain, un-suffixed name, so "DHitScanTrapTDelayed" != "DHitScanTrapDelayed" and the branch is never taken — DoOn/DoOff and repeat-rescheduling never fire. The same un-suffixed-vs-suffixed mismatch applies to the Falloff timer check (line 1765) and to the `IsDataSet(_script+"InfRepeat")` check in OnBeginScript (line 1653), which was stored under the T-suffixed key. No code path restores `_script` to its T-form before these later checks, and `_TModus` (the only other T-mode flag) is never consulted by OnTimer/OnBeginScript. This is a genuine, precisely-located defect that matches the claimed failure scenario exactly; it is a specific manifestation of the general `_script`-juggling hazard documented in T-91 (DTrigger's T append/slice combined with async timer callbacks), so the relation to T-91 is reasonable even though T-91's phrasing emphasizes "missed restore" rather than "correctly-restored-but-too-early" timing. + +## Cleanup items (2) + +- **Line 2189:** TriggerMessages() sets `_TModus = false` twice on the `dotrigger==true` path (once inside the `if`, immediately followed by the same assignment again right after the if/else) — redundant, harmless but should be deduplicated. +- **Line 2192:** TriggerMessages() has no explicit return on the outer-Condition-false path (falls off the end, implicitly returning null) while the success/failure paths inside the condition explicitly `return dotrigger` (a bool) — inconsistent return typing for what should be one boolean-shaped result. Both are falsy so current callers are unaffected, but it's an easy trap for a future caller doing strict `== false` checks. + +## Incomplete items (2) + +- **Line 2087:** DSendMessage's "a second [[" branch (double-bracket Stim intensity, e.g. "[[expr]]Stim") is undocumented anywhere in the class docstring, README, or OPEN_TASKS, and its output (ar[0] retains the inner brackets, e.g. "[5]" rather than "5") is only meaningful if DCheckString's leading-'[' switch (Core.nut:1033+) recognizes the resulting token — plausible as a way to let a bracket-operator like [random...] serve as the Stim intensity, but there is no evidence in the codebase that this path is exercised or tested, and it isn't listed among the T-81 "document the new operators" list. — Needs either documentation of intended use or removal if unused/dead. +- **Line 2141:** DoOff()'s ToQVar handling carries the author's own TODO and currently does the same thing as DoOn() (re-Sets the QVar to SourceObj) instead of clearing/deleting it. — Comment reads `// TODO: Add new: delete config var.` — as written, turning a trap Off does not distinguish itself from turning it On for ToQVar purposes, so any consumer relying on the QVar being cleared on Off will not see that behavior. + +## Suggestions (4) + +- **Give DGetParam(_script + "PostMessage") an explicit `true` default in DRelayMessages, matching DSendMessage's own signature default, so the silent Send-vs-Post flip (see bug list) is fixed and the intended default is obvious from the call site.** — All the other fallback lookups in the same DMultiMessage/DRelayMessages call chain thread explicit defaults through their DGetParamRaw chain; this one line is the odd one out and currently overrides rather than falls back to the documented default. +- **Either implement and document an independent `[Script]TCopies` parameter for DTrigger's T-mode copy handling, or simplify DTrigger.RepeatForCopies to skip the second (_TModus=true) base call entirely for every func except the ones that genuinely need T-namespaced Copies.** — As written, the second pass is a guaranteed no-op for every real script in the repo (no "XxxTCopies" parameter exists anywhere), yet it doubles the DGetParam/userparams() cost of every RepeatForCopies invocation and also masks the base class's real return value (see the always-returns-true bug), so it is pure liability with no working benefit today. +- **Document (or rename) the overload of the letter "T" — DRelayTrap's `TOn`/`TOff` ("the message to send") versus DTrigger's `_script+"T"` ("the independent trigger-side timing namespace") are two unrelated conventions that happen to share a prefix, which is easy to mix up when writing or reading a Design Note for a DTrigger-derived script.** — T-81 already tracks documenting the new operators generally; calling out this specific naming collision would save future maintainers (and mission authors) real confusion, especially since DHitScanTrap/DObjectPanTo/DDirector/DRenameItem all inherit both conventions simultaneously. +- **Add a defensive length check at the top of DSendMessage before indexing msg[0]/msg[1], mirroring the guard style already used elsewhere in DCheckString.** — Cheap to add, and prevents a malformed/empty message string in a `+`-joined list from aborting the whole DMultiMessage loop instead of just being skipped or treated literally. + +## Candidate findings rejected on verification (1) + +_Kept here for transparency — reviewed and adversarially checked, but not confirmed as real defects._ + +- **Line 2081:** DSendMessage indexes msg[0]/msg[1] with no length check before deciding the Stim-vs-normal-message format, so a 0- or 1-character message string throws instead of being treated as a plain message. — _refuted:_ The unguarded `msg[kGetFirstChar]`/`msg[1]` reads at Core.nut:2081/2087 are real as written, but the claimed trigger path doesn't survive tracing the actual call chain. Every production caller of DSendMessage (DMultiMessage:2114, HitScanTrap:General.nut:189) gets `msg` from `DGetParam(..., kReturnArray)`, which runs the raw Design Note string through `DCheckString` first. A bare empty-string parameter already crashes earlier, inside DCheckString's own `switch(str[kGetFirstChar])` at Core.nut:1033 (str[0] on "" is out of range) — before any array reaches DSendMessage. A "[" typo likewise crashes earlier, at the already-tracked T-26 site (Core.nut:1083, `str[1] == '|'`), for the same reason, never surviving to reach line 2081/2087. The "+"-joined-list half of the scenario is additionally contradicted by documented split() semantics (CLAUDE.md, confirmed at Core.nut:1356-1361): `::split()` drops empty tokens, so a "+"-joined TOn/TOff list can never yield an empty-string array entry in the first place. So while the missing guard at 2081 is technically present in the code, it is unreachable via the mechanisms described (Design Note TOn/TOff parsing) because DCheckString already throws upstream on the same malformed inputs — this is not an independently triggerable new defect, just an unreachable restatement of the already-tracked T-26 pattern. diff --git a/docs/review/wave1/dscript-namespace.md b/docs/review/wave1/dscript-namespace.md new file mode 100644 index 0000000..e348d22 --- /dev/null +++ b/docs/review/wave1/dscript-namespace.md @@ -0,0 +1,101 @@ +# DScript (library namespace) + +**File:** `DScript Core.nut` · **Anchor:** line 188 + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +The `DScript` table (Core.nut:188-983) is the framework's stateless utility layer: type/string helpers, object-set traversal for the `@`/`*`/`&`/`]` operators, geometry math, and the QVar read/write/delete machinery, plus the `_tempstore`/`CompileExpressions` engine behind the `_expr_` operator. It is clearly load-bearing and fragile exactly where CLAUDE.md and OPEN_TASKS already warn: several already-tracked bugs (T-22, T-23, T-25, T-64, T-90) live in this exact code. Beyond those, this pass found a cluster of new, high-impact defects concentrated in the QVar write/delete path (`SetQVar` calling a nonexistent `DScript.Quest.QuestChange` member on virtually every non-default call; `DeleteQVar` and `_GetQVarType` both comparing/indexing with a `null` default `type` or a possibly-null Bin table in ways Squirrel cannot support) and in the `_tempstore.APPEND` helper backing the `\"` append operator (the array/string branches silently return `null` instead of the appended result in the common case). Several smaller edge-case bugs (empty-array `ArrayToString`, `find()`-vs-null in `DGetStringParamRaw`, delete-during-foreach in `_tempstore._call`) round out the picture. Net effect: the QVar system (`GetQVar`/`SetQVar`/`DeleteQVar`), which most of the rest of the framework depends on, looks considerably more broken in its common (non-default-type) code paths than the existing task list currently reflects. + +## Confirmed bugs (11) + +### Line 207 — `ArrayToString` indexes `ar[maxIndex]` with `maxIndex = ar.len() - 1`, which is `-1` for an empty array, indexing out of range instead of handling the empty case. (new finding) + +- **Severity:** P2 +- **Failure scenario:** `ArrayToString([])` sets `maxIndex = -1`; the `for` loop body never runs (0 < -1 is false), and the final `return data += ar[maxIndex]` becomes `ar[-1]` on a zero-length array, which is out of bounds and throws instead of returning an empty string. +- **Verification:** Confirmed by reading `DScript Core.nut:203-211` and by empirically building and running the reference Squirrel 3.2 interpreter. `ArrayToString(ar, separator)` computes `maxIndex = ar.len() - 1` (line 207); for `ar=[]` this is `-1`. The `for` loop guard `i < maxIndex` (line 208) is `0 < -1`, false, so the loop body never executes, exactly as claimed. The final line `return data += ar[maxIndex]` (line 210) then evaluates `ar[-1]`. Testing against a built Squirrel interpreter shows arrays do NOT support Python-style negative indexing (unlike strings, which do — confirmed the codebase's own `_script[-1]` usage at Core.nut:1628 relies on that string-only behavior): `ar[-1]` on both an empty array and a populated 3-element array throws `the index '-1' does not exist`. So `ArrayToString([])` throws instead of returning `""`. This is reachable in practice via `DSetTimerData(name, delay)` / `DSetTimerDataTo(To, name, delay)` (Core.nut:1517-1525) called with zero extra vararg data items, which passes an empty `vargv` straight into `ArrayToString`. No guard exists anywhere in the function or its callers for the empty-array case, and the issue is not listed in `docs/OPEN_TASKS.md`. The finding is accurate and not previously tracked. + +### Line 437 — `FindClosestObjectInSet` seeds `minDist` with a hardcoded `8000` "big value" sentinel instead of infinity or the first candidate's distance. (new finding) + +- **Severity:** P3 +- **Failure scenario:** On a map where every object in `objset` is farther than 8000 units from `anchor` (plausible on large outdoor/terrain missions), no `curDist < minDist` comparison ever succeeds, and the function returns `null` even though a genuine closest object exists among non-empty `objset`. +- **Verification:** Confirmed by reading DScript Core.nut:434-447 (the entire function body, no earlier or later guard). `minDist` is seeded with literal `8000` at line 437, explicitly commented "random big value", and the only comparison is `if (curDist < minDist)` at line 441 — there is no fallback that initializes minDist from the first element's distance or from infinity/int max. If every object in a non-empty `objset` is farther than 8000 units from `anchor`, the condition never triggers, `retObj` stays at its initial `null` (line 438), and the function returns null despite a genuine closest candidate existing. Thief/DromEd distances are in inches and large outdoor maps can easily exceed 8000 units, so the failure scenario is plausible rather than contrived. grep confirms this defect is not mentioned in docs/OPEN_TASKS.md, so it is indeed new. The bug is real, low severity (P3 is reasonable — it silently degrades a helper rather than crashing), and exactly matches the claim. + +### Line 453 — `ObjectsInNet` (line 453) and `ObjectsLinkedFromSet` (line 485) both test `if (!objset.find(nextobj))`, treating index `0` (a legitimate "already present at position 0" result) as "not found". (tracked: T-23) + +- **Severity:** P2 +- **Failure scenario:** If the very first object appended to `objset`/`foundobjs` is re-encountered again via another link, `objset.find(nextobj)` returns `0` (falsy in Squirrel), so `!0` is true and the object is treated as new and appended again, producing duplicate entries (and, for `ObjectsInNet`, a set whose length no longer matches the true traversal, feeding back into the T-22 index bug). +- **Verification:** Verified by reading DScript Core.nut:449-494. Line 453 (`ObjectsInNet`) and line 485 (`ObjectsLinkedFromSet`) both use `if (!objset.find(nextobj))` to check "already present", while the analogous `ObjectsInPath` at line 467 correctly uses `if (objset.find(nextobj) == null)`. In Squirrel, `find()` returns `null` when absent or the matching index (which can be `0`) when present; `0` is falsy, so `!0` is `true`, meaning a re-link to the array's first element (index 0) is wrongly treated as "not found" and appended as a duplicate. This is a real, exploitable truthiness bug (0 vs null), not a misreading — the code path in ObjectsInNet at line 451-457 has no other guard that would prevent it, and objset[0] is a legitimate, reachable find() result since objset is seeded with the starting object(s). The claim matches docs/OPEN_TASKS.md T-23 (line 77) verbatim, including the same file:line citations (453, 485) and the same correct-counterexample reference (ObjectsInPath:467). The severity (P2) and cross-reference to T-22 (line 459, a distinct off-by-one in the loop-termination test) are also accurate and consistent with the tracked issue list. + +### Line 458 — `ObjectsInNet`'s recursion end-condition `if ( !objset.len() == cur_idx )` parses as `(!objset.len()) == cur_idx`, so net traversal always stops after processing index 0 (one hop) regardless of how many branches remain. (tracked: T-22) + +- **Severity:** P2 +- **Failure scenario:** A `&=`, but `find()` returns `null` when the substring is absent. (new finding) + +- **Severity:** P2 +- **Failure scenario:** When `param` is not present in `str`, `str.find(param)` returns `null`. `key >= 0` then compares `null` to an integer, which Squirrel does not support for relational operators and throws, instead of falling through to `return defaultValue` as intended. This is the same `find()`-returns-null gotcha already tracked elsewhere in the file (T-23) but manifesting through a relational compare rather than a falsy-test, in a function not covered by that task. +- **Verification:** Confirmed by reading DScript Core.nut lines 465-526. At line 508-512, `DGetStringParamRaw` does `local key = str.find(param); if (key >= 0){...} return defaultValue;` with no null check before the relational compare. `find()` returns `null` (not a negative number) when the substring is absent, per Squirrel semantics documented elsewhere in this same file's fold (`DivideAtNext` just above it, lines 497-505, correctly guards with `if (i == null) return [str, ""]`). In Squirrel's VM, `ObjCmp` raises a runtime "compare between different types" error when operands are of different types and not both numeric, so `null >= 0` throws rather than falling through to `return defaultValue`. This is a distinct manifestation (relational-compare-on-null) from the falsy-test bugs at lines 459/485 already tracked as T-23, and grepping docs/OPEN_TASKS.md shows no entry referencing line 508-512 or DGetStringParamRaw, so it is indeed untracked. The function is live (called from line 2586, likely backing a public DGetParam-family API), so the failure path is reachable whenever a queried parameter is simply absent from the string — a common, non-edge-case occurrence. This matches the claim exactly. + +### Line 549 — `_tempstore.APPEND`'s "array" and "string" branches never return `to`/`rv` unless the result exceeds `maxLength`, and the array branch calls `.len()` on the raw appended `value`, not on the array. (new finding) + +- **Severity:** P1 +- **Failure scenario:** `APPEND(value, to)` imitates the `"` operator. For `typeof(to)=="array"`: `to.append(value)` mutates the array correctly, but `rv` is left equal to the single `value` that was appended (not `to`). The trailing `if (rv.len() > maxLength) return rv.slice(-maxLength)` then (a) throws if `value` has no `.len()` method (e.g. appending an integer object id, which is exactly the kind of value `"`-appending object sets would use), or (b) if `value` does have `.len()` and it's short, falls off the end of the function and implicitly returns `null` instead of the appended array. Same shape for `typeof(to)=="string"`: `rv = to + value` is computed correctly but is only returned when the concatenated string exceeds `maxLength`; short strings (the common case) cause an implicit `null` return, silently discarding the real result of every `"`-style append that doesn't overflow. +- **Verification:** Reading DScript Core.nut:540-564 confirms the claim exactly. `local rv = value` (541) is never reassigned in the `"array"` case (549-551): `to.append(value)` mutates the array in place but `rv` still holds the raw appended element, so the trailing check at 562 `if (rv.len() > maxLength) return rv.slice(-maxLength)` calls `.len()`/`.slice()` on `value` (not the array) — throwing for non-`.len()`-able values (e.g. an integer object id) and, for short values, falling off the end of the function with an implicit `null` return instead of the appended array. The `"string"` case (552-554) does compute `rv = to + value` correctly, but there is no `return rv` after the switch except inside the `if` — so any concatenation not exceeding `maxLength` (the common case) also returns implicit `null`. The `"float"/"integer"` case is the only branch with its own explicit `return rv` (560), confirming the other two branches were meant to fall through to a final return that was never written. This is a genuine, previously-untracked defect (grep of docs/OPEN_TASKS.md shows no APPEND/_tempstore.APPEND entry; T-90/T-91 concern unrelated stack-depth and `_script`-restoration issues), and matches every part of the claimed failure scenario. + +### Line 712 — `_GetQVarType` does `name in ::Quest.BinGetTable(kSharedBinTable)` without checking whether the shared Bin table exists yet. (new finding) + +- **Severity:** P1 +- **Failure scenario:** Elsewhere in this same file (`SetQVar`, line ~837) the author explicitly guards the identical call with `if (!table) table = {...}`, showing `::Quest.BinGetTable()` returns a falsy value (null) when the named Bin doesn't exist yet. In `_GetQVarType`, before any `kScalarCampaign` QVar has ever been written in a campaign, `::Quest.BinGetTable(kSharedBinTable)` returns null, and `name in null` is not a supported `in` target in Squirrel, so the very first QVar type lookup of a campaign throws. `_GetQVarType` is called from `GetQVar`, `SetQVar`, and `_DoQVarChecks`, so this can break the first QVar operation of any kind. +- **Verification:** Confirmed as a real bug. In DScript Core.nut, `_GetQVarType` (line 699) falls through to line 712: `if (name in ::Quest.BinGetTable(kSharedBinTable)) return eDQVarType.kScalarCampaign` with no preceding `::Quest.BinExists(kSharedBinTable)` check and no post-call null guard. Every other BinGetTable(kSharedBinTable) call site in the file guards against non-existence: GetQVar's kScalarCampaign case (line 736) checks `::Quest.BinExists(kSharedBinTable)` before calling BinGetTable, and SetQVar (lines 837-839) calls BinGetTable(kSharedBinTable) unguarded but immediately does `if (!table) table = {[name]=value}` — proving BinGetTable returns a falsy/null value when the named Bin doesn't yet exist. Squirrel's `in` operator only operates on table/array/class/instance; applying it to null throws a runtime error, so on a fresh campaign (before any kScalarCampaign QVar has been written) line 712 throws. Since _GetQVarType is invoked from GetQVar, _DoQVarChecks (used by SetQVar), and the tempstore accessor for any QVar name whose own Bin doesn't exist (i.e., whenever line 702's BinExists(name) check fails), this breaks the first QVar operation of essentially any type in a campaign, matching the claimed failure scenario. Not tracked in docs/OPEN_TASKS.md, consistent with it being a new finding. + +### Line 864 — `SetQVar`'s NonScalarMission bookkeeping writes the campaign-scoped index of mission-only Bin tables under the key `"MisBinTables"` (one `s`). (tracked: T-34) + +- **Severity:** P2 +- **Failure scenario:** The tracked companion bug (T-34) documents that mission-cleanup code elsewhere reads back `"MissBinTables"` (two `s`s); the write site living in this feature's `SetQVar` (lines 864-876) is the other half of that same mismatch, so the mission-scoped Bin tables this code registers are never found and purged on mission end/campaign switch, leaking stale Bin data across missions. +- **Verification:** Confirmed by direct read of DScript Core.nut:820-889. Lines 864, 865, 868, and 876 all write/read the campaign-scoped Bin table key as "MisBinTables" (single 's') when registering a mission-scoped Bin table name under the eDQVarType.kNonScalarMission case in SetQVar. The mission-cleanup code at line 2320-2330 (confirmed via grep) reads/deletes "MissBinTables" (double 's'). This is a genuine string-literal key mismatch, not a truthiness or precedence misreading — grep -a over the whole file shows exactly these two distinct spellings and no other occurrences, so the two sites can never see each other's writes. This exactly matches the tracked issue T-34 in docs/OPEN_TASKS.md ("DScript Core.nut:2320` vs `864-876`... Mission-scoped bin tables are never purged between missions"), confirming both the location and the mechanism described in the claim. + +### Line 899 — `DeleteQVar`'s guard conditions mix `&&`/`||` with relational operators (`<`, `>=`) against the default `type = null` parameter, which Squirrel cannot compare to an integer. (new finding) + +- **Severity:** P1 +- **Failure scenario:** `DeleteQVar(name)` is called with the default `type=null` (the common calling pattern for `DTrapDeleteQVar`). Because `&&` binds tighter than `||`, the guard `::Quest.BinExists(name) && type==null || type < eDQVarType.kNonScalarCampaign` only short-circuits away from the relational compare when `BinExists(name)` is true; if the QVar is NOT stored as a Bin table (e.g. it's a plain Quest var or ScalarMission var), `BinExists(name)` is false, so Squirrel evaluates `type < eDQVarType.kNonScalarCampaign` with `type == null`, which is a compare between incompatible types and throws at runtime. The same shape recurs at line 915 with `type >= eDQVarType.kIntegerMission`. Deleting the most common (non-Bin) QVar kinds with the default type argument is broken. +- **Verification:** Confirmed real. In Squirrel, && binds tighter than ||, so line 899 parses as (BinExists(name) && type==null) || (type < eDQVarType.kNonScalarCampaign). With the default call DeleteQVar(name) (type=null, the pattern used by DTrapDeleteQVar when no Type param is given, Core.nut:2965), if the QVar is not stored as a Bin table, BinExists(name) is false, making the left && clause false; Squirrel then must evaluate the right operand `type < eDQVarType.kNonScalarCampaign`, i.e. `null < -3`. eDQVarType.kNonScalarCampaign is a plain integer (Core.nut:103), and null has no _cmp delegate, so this relational comparison between incompatible types raises a runtime error in Squirrel, aborting DeleteQVar before the try/catch at line 900 is even reached. The identical shape recurs at line 915 (`!type` only guards the left `&&` clause; if Quest.Exists(name) is false, `type >= eDQVarType.kIntegerMission` still evaluates null >= 0 and throws). Notably, the file's own SetQVar function (line 810: `max_allowed && max_allowed < _DQVarType`) shows the correct idiom — truthy-guarding the *same* variable before comparing it — which DeleteQVar fails to do, guarding on an unrelated BinExists()/Quest.Exists() condition instead. This is not tracked in docs/OPEN_TASKS.md (only the unrelated generic T-83 "document the QVar system" mentions DTrapDeleteQVar), so it is indeed a new, unlisted finding. + +### Line 926 — `_GetInstance`/`_tempstore._get` walk the call stack with hard-coded depths (4, 5, 7); any added or removed call frame in this code path silently breaks variable/self resolution for every `_expr_` and CompileExpressions call. (tracked: T-90) + +- **Severity:** P2 +- **Failure scenario:** Already tracked design risk: adding/removing a wrapper call anywhere between a script's method and `DScript._tempstore._get`/`_GetInstance` (e.g. refactoring `CheckAndCompileExpression` or `CompileExpressions`) changes the real stack depth, so `getstackinfos(5)`/`getstackinfos(7)` in `_get` (line 629) or the `i=4` default in `_GetInstance` (line 926) reads the wrong frame's locals, silently resolving to the wrong `self`/local variable or falling through to the root-table/tostring fallback with no error raised. +- **Verification:** Confirmed by direct reading. `_GetInstance` (Core:926) defaults `i=4` and calls `::getstackinfos(i)`, then walks `while (::type(stack.locals[\"this\"]) != \"instance\") { i++; ... }` (lines 930-935) — this has partial self-correction but still assumes a specific starting depth and will latch onto the first instance-typed `this` it meets, which need not be the intended caller if an extra frame in between also binds `this` to an instance. More critically, `DScript._tempstore._get` (Core:629, 636) has no such correction at all: it calls `::getstackinfos(5)` and, on the `acall` (CheckAndCompileExpression) path, `::getstackinfos(7)`, with the exact depths hand-documented in the comments at lines 625-628 for the two known call chains (via `CompileExpressions` vs via `CheckAndCompileExpression`). Any added/removed intermediate call (e.g. refactoring either of those functions) would silently shift which frame's `.locals` is read, matching the claimed failure mode exactly, and there is no error path—`_get` falls through to root-table lookup or `key.tostring()` (line 643) instead of raising. This also verbatim matches the already-tracked `docs/OPEN_TASKS.md` entry T-90 (`DScript Core.nut:926, 629`, same wording about hard-coded depths 4/5/7 and no test catching it), confirming both the location and the characterization are accurate rather than a misreading. + +## Cleanup items (3) + +- **Line 616:** Commented-out "safer method to get self" block left in `_tempstore._get` (an alternative `__getTable`-based lookup the author tried and abandoned in favor of the stack-walking approach) +- **Line 828:** `SetQVar` unconditionally builds and prints a debug info string on every single call via `DPrint("\nINFO: Saving '" + name ...)` under a bare `# DEBUG POINT` comment tag — this is the QVar system's hot path (every `DTrapSetQVar`/`DTrigQVar` write) and the print is not gated behind any debug flag +- **Line 880:** `SetQVar`'s Bin-table save path is guarded by an odd `if (DPrint("Contained Table data:")) ::DTestTrap.DumpTable(table)` under a `#TEST` tag — using `DPrint`'s return value as a boolean gate for a debug dump call is a fragile leftover-test idiom rather than a real debug switch + +## Incomplete items (1) + +- **Line 220:** `_FormatForReturn`'s empty-array fallback value is an open author question — Comment reads `// If the array is empty return 0. TODO 0 was better because..., where is this bad?` — the author is not confident `0` is the right sentinel for every caller of `DCheckString`/`_FormatForReturn`; worth auditing call sites before relying on it. + +## Suggestions (4) + +- **`_tempstore._get`'s final fallback silently returns `key.tostring()` for any identifier that resolves nowhere (not `this`, not `self`, not a Quest var, not a stack local, not a root-table global), rather than throwing.** — A commented-out `// throw null // not found` right below shows the author considered erroring here and chose not to. This means a typo'd variable name inside a `_expr_` silently becomes a string literal of its own name instead of failing loudly, which can mask authoring mistakes in mission Design Notes for a long time before anyone notices the value looks wrong. +- **Consider giving `_tempstore` a per-call life cycle (or a small pool) instead of a single shared singleton table that gets wiped-and-reused via `_call`.** — Because `_tempstore` is one global table, any reentrant call to `CompileExpressions`/`CheckAndCompileExpression` while another is still logically 'in scope' (e.g. one expression's evaluation triggers another, such as `APPEND`'s `OBJSET` calling back into `DCheckString`) risks the inner call's reset (`_call`) clobbering `THIS` and deleting slots the outer evaluation still needs, in addition to the foreach/delete hazard noted above. +- **`GetModelDims` creates and destroys a real `Marker` object every invocation to read `PhysDims`.** — Callers like `ScaleToMaxSize` may invoke this in a loop over many objects; caching results per model filename (a table keyed by model name) would avoid repeated `Object.BeginCreate`/`EndCreate`/`Destroy` churn. +- **`CheckAndCompileExpression`'s parity check `!(data.len() % 2)` assumes `split(str, "_")` preserves empty tokens at the string boundaries.** — This codebase's own documented gotcha is that Squirrel's `split()` drops empty tokens; strings that begin or end exactly on an underscore boundary (e.g. a whole-string expression like `"_GetQVar_"`, or an escaped `"__"`) will produce a different token count than a naive mental model expects, which could make the 'missing additional _' warning fire on valid input or stay silent on genuinely malformed input. Worth double-checking against a few boundary-case Design Note strings by hand. + +## Candidate findings rejected on verification (2) + +_Kept here for transparency — reviewed and adversarially checked, but not confirmed as real defects._ + +- **Line 667:** `_tempstore._call` deletes keys from `this` while iterating `this` with `foreach`, the same mutate-during-foreach hazard documented elsewhere in this codebase (T-49) for arrays, here applied to the shared singleton expression-evaluation table. — _refuted:_ Read DScript Core.nut lines 620-696. The `_call` meta-method (line 662-670) loops `foreach (key, entry in this)` and calls `delete this[key]` (line 667) only for the exact key that was just yielded by the same foreach iteration (skipping "THIS" and "_Ref"+self+"_*" via `continue` at line 665-666). Squirrel's table foreach is implemented via sequential native-array-index traversal (SQTable::Next), which fetches the key/value pair and advances its internal index cursor before the loop body executes; deleting the *current* already-yielded slot does not affect that cursor and is the documented-safe idiom for clearing a table mid-iteration. This is fundamentally different from the array-mutation hazard the finding cites as precedent (T-49, `docs/OPEN_TASKS.md:123`; line shifted from :115 by the 2026-08-04 merge), which is specifically about removing an *array* element inside its own foreach, causing subsequent elements to shift down and get skipped — arrays are contiguous and re-index on removal, tables are not. No new keys are ever added during the loop (rawset("THIS", main) only updates an already-existing slot, never growing/rehashing the table), so the "hash-slot reshuffling" mechanism the finding invokes for its cross-script leakage scenario doesn't apply either. The finding conflates a real, already-tracked array hazard with an unrelated and actually-safe table-deletion pattern, so the claimed defect does not hold. +- **Line 890:** SetQVar's post-switch statement calls a nonexistent member `DScript.Quest.QuestChange` (there is no `Quest` slot on the `DScript` table) and the intended notification line right after it is unreachable dead code. — _refuted:_ The claim that "there is no `Quest` slot on the `DScript` table" is false: `DScript Core.nut:2818` defines `DScript.Quest <- { ... }` as a table with `SubscribeMsg`, `UnsubscribeMsg`, `DeregisterTrigger`, and `QuestChange` (line 2861) methods. This table-literal assignment runs at script-compile/load time (before any `SetQVar` call can occur at runtime), so by the time line 890 executes, `DScript.Quest.QuestChange` resolves to a real function — no `_get` delegate miss, no thrown "not found" error. The claimed failure scenario (every non-default-case `SetQVar` call throwing at line 890) is therefore fabricated. What actually is broken, and already tracked, is `T-64`: line 891 (`::DHandler.Extern.DQVarHandler.QuestChange(...)`) is genuinely unreachable dead code after the `return` on line 890, rated P3 — a much narrower, different defect than the one asserted (which claimed line 890 itself crashes and treated 891 as the "intended" reachable line). diff --git a/docs/review/wave1/dscripthandler.md b/docs/review/wave1/dscripthandler.md new file mode 100644 index 0000000..3305023 --- /dev/null +++ b/docs/review/wave1/dscripthandler.md @@ -0,0 +1,88 @@ +# DScriptHandler + +**File:** `DScript Core.nut` · **Anchor:** line 2229 + +**Status:** Needs cleaning · Incomplete · Contains bugs · Has suggestions + +## Overall assessment + +DScriptHandler is the singleton hub for the framework's two update subsystems (throttled PerFrame_ and true per-frame PerMidFrame_ via the overlay), plus overlay and extern-handler registration. The PerMidFrame_/overlay side is internally consistent and does correctly rebuild and resume across save/load. The PerFrame_ side, however, has a real save/load survival gap: the registry table is rebuilt and repopulated, but the self-perpetuating OnDoUpdates message chain that actually drives it is only ever started on the very first-ever registration in a mission and is never restarted on a later load, so PerFrame_-driven features go silently inert after one save/reload. Separately, the generic cleanup path (IsRegistered/DeRegisterAll, invoked from every DBaseTrap's OnEndScript) indexes both database tables with no null guard, which is a plausible crash for any mission that never happens to exercise one of the two subsystems, and the Copies-recursion fallback in DeRegisterAll mis-binds `this` on recursion in a way that would throw for Copies-enabled, unregistered instances. Several already-tracked issues (T-33, T-34, T-36, T-39) also live in this exact code and are confirmed as described. Around the edges there is a fair amount of dead/inert code (an unused CallbackExtern hook, a commented-out `_get()` metamethod, an apparently unused ReRegisterWithKey dispatcher, stray debug prints, and one duplicated cleanup block) consistent with the file's own "not stable, only minimally tested" framing. + +## Confirmed bugs (8) + +### Line 2297 — PerFrame_ update loop (OnDoUpdates self-repost chain) is never restarted after a save/reload, even though PerFrame_database is correctly rebuilt. (new finding) + +- **Severity:** P1 +- **Failure scenario:** A mission uses any PerFrame_-registering feature (e.g. a Delay="3F" repeat, DTweqDevice, etc.), the player saves and reloads. On load, DScriptHandler's constructor (2243-2244) resets PerFrame_database={} and each registered trap's own OnBeginScript (DBaseTrap, Core:1652-1665) correctly calls ::DHandler.PerFrame_ReRegister(this, delay) to repopulate the table. But the only place that ever issues PostMessage(self,"DoUpdates",0) to kick off the perpetual per-N-frame tick loop is inside PerFrame_Register's `if (!IsDataSet("PerFrame_Active"))` first-time branch (line 2379-2384) - which will NOT run again because "PerFrame_Active" is already set from before the save. DScriptHandler.OnBeginScript (2297-2335) has no equivalent restart of the OnDoUpdates chain. Net effect: PerFrame_database looks populated but OnDoUpdates is never called again, so every PerFrame_-driven feature silently stops updating for the rest of the session after the first save/reload of a mission that ever used it. +- **Verification:** Verified against full source. DScriptHandler.constructor (2243-2244) only resets PerFrame_database when IsDataSet("PerFrame_Active") is true (i.e. it was persisted from before the save), and DBaseTrap.OnBeginScript (1652-1665) repopulates it via PerFrame_ReRegister (2371-2374), which merely writes a table entry and never calls PostMessage. The only call that kicks off the self-reposting OnDoUpdates loop is line 2384, PostMessage(self,"DoUpdates",0), and it lives inside the `if (!IsDataSet("PerFrame_Active"))` branch (2379) of PerFrame_Register — guaranteed false on any reload of a mission that had ever set the flag, since SetData-backed flags persist across saves (as the architecture doc itself states) while PostMessage-scheduled messages do not survive save/reload. DScriptHandler.OnBeginScript (2297-2335) has no code touching PerFrame_database or PostMessage("DoUpdates"...) to compensate. Grepping the whole file confirms "DoUpdates" is posted only at lines 2384 and 2406 (self-repost inside OnDoUpdates itself), so once the chain is broken by a save/load there is no other path to revive it short of PerFrame_database fully draining to empty (clearing PerFrame_Active) and a fresh PerFrame_Register call. This is not previously tracked in docs/OPEN_TASKS.md (T-39 is the only PerFrame/DoUpdates-adjacent entry and covers an unrelated coupling issue). The claim holds up exactly as described against the real code paths and Squirrel/engine persistence semantics. + +### Line 2318 — Mission-init flag is written as "MissionInitialized" but checked as "MissionInizialzed", so the mission-start-only cleanup block runs on every load, not just mission start. (tracked: T-33) + +- **Severity:** P2 +- **Failure scenario:** DScript Core.nut:2802 reads the same misspelling elsewhere in the file, per the existing task entry. +- **Verification:** Verified directly: line 2318 checks IsDataSet(\"MissionInizialzed\") but line 2332 writes SetData(\"MissionInitialized\") — different string keys due to a transposed 'a'/'z', so IsDataSet at 2318 never becomes true from this write and the mission-start-only Bin-cleanup block (2319-2331) executes on every OnBeginScript call, including save/load reloads, not just true mission start. Line 2802 independently reads the same misspelled \"MissionInizialzed\" key via ::DHandler.IsDataSet, confirming the typo recurs elsewhere as claimed. This matches docs/OPEN_TASKS.md entry T-33 verbatim (same file:lines 2318/2332, same misspelling, same P2 severity, same cross-reference to line 2802). No guard elsewhere neutralizes this — the finding is real and precisely as described. + +### Line 2320 — Bin-table cleanup reads "MissBinTables" while SetQVar elsewhere writes "MisBinTables", so mission-scoped bin tables are never purged between missions. (tracked: T-34) + +- **Severity:** P2 +- **Failure scenario:** Quest.BinExists("MissBinTables") never finds the table actually written under "MisBinTables" (Core.nut:864-876), so the cleanup loop at 2327-2330 never runs and old bin data accumulates across missions. +- **Verification:** Read DScript Core.nut:2260-2359 and 830-919. The cleanup guard at line 2320 is `::Quest.BinExists(\"MissBinTables\")` (double-s spelling), while the writer in SetQVar at lines 864/868/876 consistently uses `\"MisBinTables\"` (single-s spelling) to both check existence and store the table. These are literally different string keys, not a truthiness/precedence subtlety and not guarded elsewhere in the method body — BinExists at 2320 will always return false for a table that was actually written under the other name, so the foreach purge loop at 2327-2330 and the BinDelete at 2330 are unreachable dead code, meaning mission-scoped bin tables accumulate across missions exactly as claimed. This exactly matches docs/OPEN_TASKS.md T-34 (line 93), which cites the same locations (2320 vs 864-876), same root cause, and same fix suggestion (unify the name, ideally as a const). No evidence of any surrounding guard that would make the mismatch harmless. + +### Line 2340 — CreateHashKey's %04u fixed-width format collides for negative archetype object IDs and any ID over 9999. (tracked: T-36) + +- **Severity:** P3 +- **Failure scenario:** An archetype-derived object ID like -123 formatted with %04u, or any real object ID >= 10000, produces a hash key that is ambiguous with a different (id,_script) combination once concatenated with _script, causing two distinct instances to alias to the same PerFrame_/PerMidFrame_ database slot. +- **Verification:** The quoted line matches exactly: DScript Core.nut:2340 is `return ::format("%04u%s",instance.self, ("_script" in instance)? instance._script : instance.GetClassName())`, used as a dictionary key in PerFrame_database/PerMidFrame_database (lines 2361-2374 confirm `key in PerFrame_database` / `PerFrame_database[key] <-`). `%04u` is a minimum-width specifier, not a truncating fixed-width one, so any id >= 10000 simply prints all its digits (e.g. id=1234+"5X" and id=12345+"X" both format to "12345X"), producing exactly the alias collision the finding describes; negative archetype IDs likewise break the 4-digit assumption because %u reinterprets the value as unsigned (yielding a large multi-digit number), again defeating the fixed-width boundary between the numeric and _script portions. No surrounding guard clamps or separates the two fields (no delimiter is used in the format string), so the claimed failure scenario is real and reachable through the documented registration path. This also verbatim matches docs/OPEN_TASKS.md T-36 (line 95), which cites the identical file:line, format string, and example collision (obj 1234+"5X" vs obj 12345+"X"), confirming the cross-reference is accurate. + +### Line 2410 — PerMidFrame_DoUpdates hard-references DHudObject.pos_vector, coupling DScript Core.nut to DScript SFX.nut. (tracked: T-39) + +- **Severity:** P3 +- **Failure scenario:** If a mod ships DScript Core.nut without DScript SFX.nut (or SFX.nut fails to load), DHudObject is undefined and every per-mid-frame overlay tick throws as soon as any PerMidFrame_ feature is registered. +- **Verification:** Verified by reading Core.nut:2391-2447 and cross-referencing SFX.nut. Line 2410 (`::Object.CalcRelTransform(::PlayerID, ::PlayerID, DHudObject.pos_vector, vector(), 4, 0)`) runs unconditionally on every PerMidFrame_DoUpdates call, before the per-instance foreach loop, with no nil-check or guard. DHudObject (and its pos_vector member) is defined only in DScript SFX.nut:398-415, not in Core.nut — a genuine cross-file class coupling. Since PerMidFrame_Register (Core.nut:2430) is a generic registration API usable by any script (its only current caller is DHudObject at SFX.nut:523, but nothing restricts it), any registration triggers this line regardless of whether SFX.nut is loaded, so a Core-only deployment would throw a runtime 'undefined identifier' error as soon as any PerMidFrame feature registers. This matches docs/OPEN_TASKS.md T-39 verbatim (same location, description, and suggested fix of moving the vector to DScriptHandler or guarding it). + +### Line 2465 — DeRegisterAll's Copies-recursion fallback rebinds `this` to the trap instance on recursive calls, breaking CreateHashKey/IsRegistered which only exist on DScriptHandler. (new finding) + +- **Severity:** P2 +- **Failure scenario:** When IsRegistered(instance) returns neither 'F' nor 'M', DeRegisterAll falls through to `RepeatForCopies.call(instance, ::callee(), instance)` to also check the instance's Copies variants. RepeatForCopies (Core:1619) does `vargv.insert(0, this)` then `func.acall(vargv)`; since `this` inside this call is `instance` (the trap, due to `.call(instance, ...)`), and `func` is `::callee()` = DeRegisterAll itself, the recursive invocation runs with `this` bound to the trap object, not to ::DHandler. Inside that recursive DeRegisterAll call, the unqualified references to CreateHashKey(...) and IsRegistered(...) resolve against `this` (the trap), which has neither method defined, producing a 'the index does not exist' error the first time DeRegisterAll is called on any instance that both uses the Copies parameter and is not currently registered in either database (a plausible and fairly common combination, e.g. any Copies-enabled trap deleted mid-mission that never used PerFrame_/PerMidFrame_). +- **Verification:** Confirmed by tracing exact Squirrel `.call`/`.acall` semantics. DeRegisterAll (Core:2459) runs with `this` = ::DHandler normally. At line 2465, when IsRegistered(instance) returns neither 'F' nor 'M' (switch falls through), it does `RepeatForCopies.call(instance, ::callee(), instance)`. This rebinds `this` = instance (the trap) for the RepeatForCopies invocation — intentional, per the comment, so `GetClassName()`/`_script`/`DGetParam` inside RepeatForCopies (Core:1619) resolve against the trap being deregistered, not DHandler. But inside RepeatForCopies, `vargv.insert(0, this)` (Core:1635) captures `this` = instance, then `func.acall(vargv)` (Core:1636) invokes `func` (= DeRegisterAll itself, captured via `::callee()`) with vargv[0]=instance as the new `this`-binding (acall's documented semantics, confirmed by the identical pattern at Core:2159-2162 where `vargv.insert(0,this); base.RepeatForCopies.acall(vargv)`). So the recursive DeRegisterAll body executes with `this` = the trap instance, not DHandler. Its first line, `CreateHashKey(instance)` (Core:2460, re-entered), plus `IsRegistered(instance)`, are unqualified calls that resolve against `this`; both are defined only inside DScriptHandler (Core:2339, 2450) and not on generic DBaseTrap-derived scripts, so the recursive call throws an "index does not exist" error. Grep confirms line 2465 is the only site in the file where RepeatForCopies is invoked via `.call` on a foreign instance (all ~13 other call sites use plain `RepeatForCopies(::callee())`, running with `this`=self, where this hazard doesn't arise), and DeRegisterAll's sole caller is `OnEndScript` (Core:2045, `::DHandler.DeRegisterAll(this)`), which fires for every DBaseTrap-derived script instance on deletion/mission end — so any Copies-enabled trap that ends its script while not registered in PerFrame_/PerMidFrame_ databases will hit this. The claim's mechanics, trigger condition, and severity assessment all check out against the actual code. + +### Line 2487 — NewOverlay's `multiple=true` name-disambiguation logic is broken: a bare expression statement discards its result and the collision loop truncates real name characters instead of only replacing a trailing digit. (new finding) + +- **Severity:** P3 +- **Failure scenario:** If NewOverlay(Name, OverlayClass, true) is ever called with a Name that collides with an existing OverlayHandlers key, line 2487 `Name + "2"` computes a new string but never assigns it back to Name (should be `Name = Name + "2"`), so it's a no-op. The following while loop then does `Name = Name.slice(0,-1) + i`, which strips one real character off the *current* Name every iteration rather than just replacing a trailing disambiguation digit - so on a second collision the name loses two real characters, on a third it loses three, etc., instead of cleanly producing "Name2", "Name3". Currently unreachable in practice (no caller passes multiple=true - only ::DHandler.NewOverlay("DWorldInvOverlay", cDWorldInvOverlay) and NewOverlay("FrameUpdater", ::cDHandlerFrameUpdater) are called, both with the default false), so this is dormant until some future script opts into `multiple`. +- **Verification:** Confirmed as a genuine bug. Line 2487 `Name + "2"` is a bare expression statement — it builds a new string but the result is discarded (Squirrel has no implicit assignment), so `Name` is unchanged going into the while loop at 2489-2492. That loop then does `Name = Name.slice(0,-1) + i`, which strips the last character of whatever `Name` currently is and appends the digit `i`. Since `Name` was never advanced to "Name2" first, the very first iteration already strips a genuine trailing character from the original name (e.g. "Name" -> slice(0,-1)="Nam", +2 -> "Nam2", losing the real 'e'), and each subsequent collision compounds the truncation as claimed. Grep confirms both real call sites (`DScript Core.nut:2442` and `DScript SFX.nut:764`) use the default `multiple=false`, so the path is currently dormant/unreachable in practice, matching the claim. This is a legitimate, currently-inert logic defect with an exact, citable root cause (missing assignment at line 2487), not a misreading. + +### Line 2505 — EndOverlay's by-class branch removes the handler from ::gGameOverlay but never deletes its entry from OverlayHandlers, leaking a stale table entry. (new finding) + +- **Severity:** P3 +- **Failure scenario:** If EndOverlay is ever called with a class reference (the `else` branch, 2505-2511) instead of a string name, `::gGameOverlay.RemoveHandler(ol)` runs but `OverlayHandlers` still retains the now-orphaned entry, so a later OnEndScript/destructor sweep (2521-2533) will call RemoveHandler on it again (harmless but wasted), and any future by-name lookup (e.g. `Name_or_class in OverlayHandlers`) will report the overlay as still present even though it has been detached from the render list. Currently unreachable - the only live caller (DScript SFX.nut:772) uses the string form - so this is latent. +- **Verification:** Confirmed by reading lines 2500-2512. The string branch (2501-2504) does `delete OverlayHandlers[Name_or_class]` before calling RemoveHandler, but the class branch (2505-2511) only calls `::gGameOverlay.RemoveHandler(ol)` inside the foreach and never removes `ol` from the `OverlayHandlers` table — no `delete` anywhere in that branch. This asymmetry is real, not a misreading: the table would retain a reference to an overlay instance that has already been detached from `::gGameOverlay`. Grep confirms the only live caller (DScript SFX.nut:772) uses the string form (`EndOverlay("DWorldInvOverlay")`), so the defect is currently unreachable/latent exactly as claimed, and the destructor/OnEndScript sweep (2521-2533) iterating `OverlayHandlers` would indeed re-invoke RemoveHandler on the stale entry. The finding accurately describes the code and is a genuine (if minor, currently-dormant) bug. + +## Cleanup items (6) + +- **Line 2250:** Dead CallbackExtern branch (2249-2255) contains two stray debug print()s (`::print("someone wants a late " + self)` and `print("Having" + instance), instance[func]()` using the comma operator) inside a code path the author's own comment says is "Currently not used". +- **Line 2260:** Commented-out `_get(key)` metamethod (2260-2267) is left in place alongside the actual (different) Extern-delegate mechanism added in RegisterExternHandler, making it unclear which is the real access path for DHandler. lookups. +- **Line 2333:** Stray `print("MissionInitialized")` left in OnBeginScript alongside the SetData call; not covered by the existing T-62 line list. +- **Line 2343:** ReRegisterWithKey/kGetFirstChar-based dispatch function appears entirely unused/dead code - the actual reregistration path is the inline logic in DBaseTrap.OnBeginScript (Core:1652-1665), which duplicates the same F/M-prefix switch without calling this function. +- **Line 2517:** OnDelete leaves a bare debug `print(Object.Exists("DScriptHandler"))` with no label, not covered by the existing T-62 line list. +- **Line 2521:** OnEndScript (2521-2526) and destructor (2528-2533) contain byte-for-byte identical overlay-removal loops; one should just call the other. + +## Incomplete items (4) + +- **Line 2249:** CallbackExtern late-registration hook is scaffolded but never wired up anywhere else in the codebase. — Author's own comment: "Currently not used" / "This table could be added [during] construction to this class" - no other file ever sets `CallbackExtern` on a DScriptHandler instance, so the whole branch is inert. +- **Line 2260:** The delegate-based DHandler. access only covers Extern-registered handlers, not OverlayHandlers, contradicting the (commented-out) documentation comment for it. — The commented `_get()` override explicitly documents `DHandler.FrameUpdater` (an OverlayHandlers entry) as an intended access pattern, but the live mechanism (`__getTable.setdelegate(Extern)` in RegisterExternHandler, 2282-2285) only chains to Extern, so `DHandler.FrameUpdater` would not resolve through it. +- **Line 2303:** Author's own unresolved TODO about a potential race in the Player-not-yet-existing retry path. — "TODO check if there is a conflict with the later declaration. it's 1ms" - flagged by the author but not investigated further; left as-is in this pass since nothing can be executed to verify. +- **Line 2515:** OnDelete's warning claims the handler "Will recreate another instance" but no recreation logic exists anywhere in the class or file. — Only a DPrint warning and a debug print(Object.Exists(...)) are present; there is no Create()/spawn call to actually replace a deleted DScriptHandler marker. + +## Suggestions (4) + +- **Guard PerFrame_database/PerMidFrame_database with an explicit null check (or initialize both to {} unconditionally in the constructor) before any `in`/foreach use.** — Would eliminate the whole null-deref crash class in IsRegistered/DeRegisterAll/PerFrame_DeRegister/PerMidFrame_DeRegister in one place instead of needing a guard at every call site. +- **DScriptHandler.OnEndScript does not call base.OnEndScript(), unlike the framework's stated convention of always chaining to base.OnX().** — DBaseTrap.OnEndScript would try to deregister the DHandler instance itself from its own registries; skipping it is probably harmless today but is an inconsistency with the rule this same codebase enforces everywhere else, and would silently start mattering if DScriptHandler ever gained PerFrame_/PerMidFrame_ registration of its own. +- **Constructor unconditionally overwrites ::DHandler via `<-` with no check for a pre-existing instance.** — Per the CLAUDE.md `<-` gotcha, if two DScriptHandler markers ever exist simultaneously (accidental duplicate in the editor, or a delayed 'recreate' per the OnDelete warning), the second construction silently replaces the global singleton and orphans the first instance's registries/overlays with no diagnostic beyond the unrelated OnDelete print. +- **CreateHashKey is computed twice in DeRegisterAll (once directly, once again inside IsRegistered).** — Minor inefficiency; could pass the already-computed key into a key-based IsRegistered variant instead of recomputing. + +## Candidate findings rejected on verification (1) + +_Kept here for transparency — reviewed and adversarially checked, but not confirmed as real defects._ + +- **Line 2450:** IsRegistered/DeRegisterAll index PerFrame_database and PerMidFrame_database with `in` before checking either is non-null, and both start out null; DBaseTrap.OnEndScript calls DeRegisterAll unconditionally for every trap instance. — _refuted:_ Confirmed the facts cited are accurate: PerFrame_database/PerMidFrame_database default to null (2232-2233) and only become tables conditionally in the constructor or Register calls; IsRegistered (2450-2457) does two unguarded `key in ...` checks; DBaseTrap.OnEndScript (2044-2046) calls DeRegisterAll unconditionally for every trap. However the claimed crash mechanism is wrong: Squirrel's `in` operator (OP_EXISTS) is implemented as a raw/metamethod-free Get() that, for operand types it doesn't recognize as a container (including null), simply falls through and returns false rather than raising an error — it only throws when actually indexing via `.`/`[]` (OP_GET) and the key is missing, or for foreach on a non-iterable, not for `in`. This is corroborated within the same class: EndOverlay (line 2502, `if (Name_or_class in OverlayHandlers)`) checks `in` against OverlayHandlers with no preceding null guard even though OverlayHandlers is also null by default (2234), the same pattern the finding calls fatal — if `in` on null actually threw, that call path would be immediately and routinely broken too, which is inconsistent with it not appearing in the tracked bug list despite OPEN_TASKS.md cataloguing far subtler issues. So IsRegistered on an all-null handler returns the FALSE sentinel (not a crash), DeRegisterAll falls into the `RepeatForCopies.call(...)` branch (2465) instead of throwing — a different, unclaimed code path, not the described runtime error. diff --git a/docs/review/wave2/SUMMARY.md b/docs/review/wave2/SUMMARY.md new file mode 100644 index 0000000..e1a3072 --- /dev/null +++ b/docs/review/wave2/SUMMARY.md @@ -0,0 +1,120 @@ +# DScript V2 Review — Wave 2: Non-Base Scripts + +**Scope:** everything Wave 1 deferred, minus two skips requested during the run: +the QVar trap classes (`DScript Core.nut:2732-2972`), `DScript General.nut` (buttons/hitscan and +utility traps), `DScript SFX.nut` (all four unit groups), `DScript File&Blob.nut` (dfile/dblob/dCSV +and the persistence classes), and `DScript Overlays.nut`. +**Skipped on request (2026-08-05):** the undercover suite (`DNotSuspAI*`, `DGoMissing`, +`DImUndercover`) and all of `DScript_ModdingTools.nut` — still unreviewed; the unit definitions are +preserved as comments in the workflow script for a later run. + +**Method:** 10 feature units. Six were reviewed by one static-review agent each (Opus for the +heavier units, Sonnet for the lighter ones) against `docs/OPEN_TASKS.md` and CLAUDE.md's gotcha +checklist, then — per this wave's ground rule of *no per-finding double review* — every file's +candidate bugs went through **one common adversarial verifier per file** (session model, high +effort), which confirmed/refuted each claim in place and retrofitted grep-able anchors into the two +reports drafted before anchors became a requirement. The remaining four units +(`sfx-ray-camera`, `sfx-inventory`, `fileblob-dfile`, `fileblob-persistence`) failed twice on +API 529 overload as subagents and were reviewed **directly by the session model in a single +review+self-verification pass** — no independent verifier; their reports carry a method note and +their "confirmed" should be read accordingly (though that pass did refute several of its own +candidates against the engine reference before writing, e.g. vector+scalar arithmetic, +`eContainType`, the engine `string()` class). + +Every finding is anchored two ways: a current-tree line number **and** a byte-exact grep-able code +fragment (`**Anchor:**` bullet), so findings stay locatable after line drift. Reminder for anchors +in the two ISO-8859-1 files: `grep -a` on `DScript Core.nut` and `DScript File&Blob.nut`. + +Per-feature detail lives in the sibling files in this folder. This file is the roll-up. + +## Headline result + +**All 10 units came back `contains_bugs` + `needs_cleaning` + `incomplete` + `has_suggestions` — +none are clean, and Wave 2's totals dwarf Wave 1's: 86 confirmed bugs (vs 55) across 4,700 fewer +lines of much less foundational code.** Two subsystems are non-functional end to end by static +trace: **DHitScanTrap** (its `DoOff()` signature alone kills every TurnOff) and the entire +**persistent-save mechanism** (four independent fatal links). A third, **DRay**, crashes on its +second activation in default configuration. + +| Unit | File | Status | Confirmed bugs | Rejected | Cleanup | Incomplete | Suggestions | Reviewed by | +|---|---|---|---:|---:|---:|---:|---:|---| +| [QVar traps](qvar-traps.md) | Core.nut:2732 | 🐛🧹🚧💡 | 15 | 0 | 12 | 5 | 7 | Opus + verifier | +| [Buttons & hitscan](general-buttons-hitscan.md) | General.nut:3 | 🐛🧹🚧💡 | 15 | 0 | 7 | 6 | 9 | Opus + verifier | +| [Utility traps](general-utility-traps.md) | General.nut:233 | 🐛🧹🚧💡 | 4 | 1 | 3 | 3 | 4 | Sonnet + verifier | +| [Ray & camera](sfx-ray-camera.md) | SFX.nut:2 | 🐛🧹🚧💡 | 12 | — | 6 | 4 | 4 | session model, single pass | +| [HUD](sfx-hud.md) | SFX.nut:398 | 🐛🧹🚧💡 | 4 | 0 | 2 | 1 | 3 | Sonnet + verifier | +| [Inventory](sfx-inventory.md) | SFX.nut:574 | 🐛🧹🚧💡 | 6 | — | 4 | 2 | 4 | session model, single pass | +| [Tweq & teleport](sfx-tweq-teleport.md) | SFX.nut:1064 | 🐛🧹🚧💡 | 4 | 1 | 2 | 5 | 4 | Sonnet + verifier | +| [dfile/dblob/dCSV](fileblob-dfile.md) | File&Blob.nut:19 | 🐛🧹🚧💡 | 11 | — | 5 | 3 | 4 | session model, single pass | +| [Persistence](fileblob-persistence.md) | File&Blob.nut:567 | 🐛🧹🚧💡 | 13 | — | 5 | 2 | 4 | session model, single pass | +| [Overlays](overlays.md) | Overlays.nut:13 | 🐛🧹🚧💡 | 2 | 1 | 4 | 2 | 3 | Sonnet + verifier | +| **Total** | | | **86** | **3** | **50** | **33** | **46** | | + +(🐛 contains bugs · 🧹 needs cleaning · 🚧 incomplete · 💡 has suggestions) + +## Most important new findings (not already in `docs/OPEN_TASKS.md`) + +1. **`DHitScanTrap` is dead in both directions** — `DoOff()` takes zero parameters while the + framework always calls `DoOff(DN)` (General.nut:215), and `DoOn` throws for any object that + doesn't set *both* `TOnResult`/`TOffResult` because the empty-string defaults crash + `DCheckString`'s `str[0]` switch (General.nut:191). Ten further confirmed bugs in the same + class (buttons-hitscan report). +2. **The whole persistent-save feature cannot run** — four independent fatal links, each + sufficient alone: multi-char `find()` on a real file throws (File&Blob:134 via :720); + `getParam2` reads from inside its own search pattern so slot detection never matches + (File&Blob:69 via :723); `GetEvent` negative-indexes a string, which Squirrel rejects + (File&Blob:813); and both relay sites call the nonexistent `base.RelayMessages` + (File&Blob:589/:861 — the method is `DRelayMessages`). +3. **`DTrigQVar` never subscribes to anything** — its registration reads the nonexistent + `"QuestVar"` property with `kReturnArray` accidentally passed in `DGetParam`'s `DN` slot + (Core.nut:2931), masking six further confirmed downstream bugs in the same class + (qvar-traps report). +4. **`DRay` crashes on its second TurnOn in default configuration** — link data stores the SFX + *name* and re-reads it with `.tointeger()`, which throws for non-numeric strings + (SFX.nut:52) — and its DoOff never destroys the created particle object because the destroy + call sits inside a comment (SFX.nut:136). +5. **A stale `FrameUpdater` entry freezes the whole PerMidFrame subsystem** — after the last + consumer deregisters, `NewOverlay` refuses to re-attach a fresh updater until save/reload + (Core.nut:2416/2493, found via the HUD unit). Cross-file, affects every PerMidFrame consumer: + DHudObject/DHudCompass, cDHandlerFrameUpdater, DObjectPanTo's per-frame mode. +6. **dfile/dblob's search core has four independent defects** — single-char string `find()` + returns the wrong operand of a comma expression (File&Blob:161), `CheckIfSubstring` indexes + streams (throws for files, :134) and overruns blob ends (:134), and `readNext` drops every + escaped character (:107). Everything built on `>`-operator file reading inherits these. + +## Cross-cutting themes + +- **The Off/cleanup paths are systematically worse than the On paths.** DRay leaks its effect, + DRenameItem's countdown survives TurnOff, DObjectPanTo kills a stale timer and can restart + itself, DStdButton and DPortal override `OnEndScript` without calling base (leaking DHandler + registrations), DDirector crashes on TurnOff-before-TurnOn, and DRelayTrap's own DoOff ToQVar + TODO was already flagged in Wave 1. Any Off-side behavior should be treated as unverified until + exercised in DromEd. +- **`GetClassName()`-vs-`_script` and hard-coded parameter names keep breaking `Copies`.** New + instances found in DRay.DoOff, DDrunkPlayerTrap (whole class), DHudObject Rotation/Spin, + DDirector Freelook, plus tracked T-48; and DRenameItem realizes the T-91 `_script`-restore + hazard concretely. A dedicated sweep for `"D[A-Z]\w+` string literals inside DGetParam calls + would catch the class wholesale. +- **Throwing string conversions (`.tointeger()`/`.tofloat()`) on link/file data are a recurring + crash source** — DRay:52, DDirector:1442/:1480, and the persistence parsers all convert + unvalidated external data where a guard or numeric-typed storage was needed. +- **Editor-only symbols leak into shipped-game paths under the documented Debug flag.** Wave 1's + DMultiMessage finding recurs at SFX:1435 (DDirector) and Core:2758 (QVar traps) — three + confirmed sites of one pattern; fix it once in DPrint or guard DTestTrap references centrally. +- **The engine-difference gotcha is real but was over-suspected**: the verifiers refuted + candidates by checking the engine reference (vector+scalar is legal, `eContainType` exists, + `SetData` returns its value, `string()` is an engine class, Squirrel's `null` compares + less-than-everything). The reference files under `DOC/squirrel_script/` earned their place — + note CLAUDE.md still points at `docs/squirrel_script/`, which does not exist (the dump lives in + `DOC/`). + +## What this wave did not cover + +- The undercover suite and `DScript_ModdingTools.nut` (both skipped on request mid-run) — the + workflow script retains their unit definitions for a quick follow-up wave. +- Runtime/behavioral confirmation in DromEd — nothing here has been run; every "confirmed" is a + static-reasoning conclusion. The four single-pass units additionally lack an independent + verifier; the one finding whose severity hinges on an unverifiable stdlib assumption + (file `[]` indexing, fileblob-dfile:134) says so explicitly and is worth one `script_test` + before acting on it. +- `DSConfigDefault.nut` / config-layer duplication beyond what T-02/T-79 already track. diff --git a/docs/review/wave2/fileblob-dfile.md b/docs/review/wave2/fileblob-dfile.md new file mode 100644 index 0000000..f3b5a33 --- /dev/null +++ b/docs/review/wave2/fileblob-dfile.md @@ -0,0 +1,235 @@ +# File&Blob — dfile, dblob, dCSV + +**File:** `DScript File&Blob.nut` · **Anchor:** line 19 (`class dfile`), line 211 (`class dblob`), line 360 (`class dCSV`) + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +**Method note:** This unit was reviewed directly by the session model (Fable) after the subagent +review failed twice on API 529 overload. Review and verification were a single pass by a single +model — each "Verification" bullet is a self-check trace against the code and the engine reference +(`DOC/squirrel_script/Custom-API-reference*.nut` / standard sqstdlib semantics), not an independent +adversarial pass. File is ISO-8859-1 (grep needs `-a`). + +## Overall assessment + +dblob's blob-backed core (writec, slice, tostring, indexing) is mostly sound, but the shared +search machinery in dfile is broken at several load-bearing points: `find()` returns the wrong +thing for single-character string patterns outright, `CheckIfSubstring` indexes the underlying +stream object — which works for blobs but, by standard-library semantics, throws for real files — +and `readNext`'s escape handling drops the escaped character. `getParam2`'s own header comment +misstates where the pointer stands after `find()`, and every one of its real callers (in the +persistence unit) inherits that error. dCSV parses simple files but silently ignores the delimiter +and comment-string arguments passed to its constructor, duplicates all rows on `refresh()`, and +crashes on a file ending in a separator. The documented `dblob + "string"` and `dblob(dfile)` +conversions both throw. Tracked row T-76 (CRLF `+1` per line) re-confirmed as an open question at +lines 14–17/47–48; not restated below. + +## Confirmed bugs (11) + +### Line 161 — find() with a single-character *string* pattern returns stopString instead of the position — the recursive call is missing (new finding) + +- **Anchor:** `return (pattern[0], myblob.tell(), stopString)` +- **Severity:** P2 +- **Failure scenario:** The intended shortcut for 1-char strings is a recursion into the integer + path: `return find(pattern[0], myblob.tell(), stopString)`. What is written is a parenthesized + comma expression, which evaluates the operands and returns the **last** one — `stopString`, + i.e. null for every normal call. So `someblob.find("\n")`, `find("=")`, or any other + string-typed single character reports "not found" regardless of content (and getParam/getParam2 + called with such a param return their defaults). +- **Verification:** The comma-expression reading is the only one consistent with Squirrel's + grammar, and the author demonstrably uses the same idiom deliberately elsewhere + (`return (myblob.seek(0), ::dblob(myblob).toblob())`, line 178) — there returning the last + operand is the point; here it discards the entire computation. Integer single-char patterns + (the common internal case) take the other branch and are unaffected, which is why the defect + can hide. + +### Line 134 — CheckIfSubstring indexes the backing stream with `[]`, which standard file objects do not support — multi-character find()/getParam on a real file throws (new finding) + +- **Anchor:** `if (myblob[tell() + i -1] != str[i]){` +- **Severity:** P1 +- **Failure scenario:** For a `dfile` (myblob is a squirrel-stdlib `file`), the first + multi-character `find("…")` reaches CheckIfSubstring and evaluates `myblob[]`. The + standard blob class registers a `_get` byte indexer; the standard file/stream class does not — + the access raises `the index '' does not exist`. That makes `dfile.getParam`, + `dfile.getParam2` and every multi-char `dfile.find` dead on arrival for actual files — including + `cDSaveHandler.GetSaveRaw`'s `File.find(eDLoad.kStart)` (line 720), which kills the whole + persistent-save feature (see the fileblob-persistence report). +- **Verification:** sqstdblob registers `_get`/`_set` metamethods for byte access; sqstdio's file + class exposes only the stream methods (readblob/readn/writeblob/writen/seek/tell/len/eos/…) — + no indexer. The engine reference documents no file extension beyond the standard class. Caveat + recorded per method note: this is standard-library reasoning, not an executed repro; if + NewDark's bundled sqstdio were patched to add `_get`, this finding (alone) would downgrade — + worth one DromEd `script_test` before acting on it. dblob-backed instances are unaffected + (their myblob is a real blob). + +### Line 107 — readNext's escape handling skips the escaped character itself and returns the byte after it (new finding) + +- **Anchor:** `myblob.seek(1,'c') // skip the next` +- **Severity:** P2 +- **Failure scenario:** On reading `\` the code seeks +1 (skipping the escaped character) and + then `readn`s the character *after* it. For a stored value `a\"b` (escaped quote inside a + `"`-separated parameter), getParam returns `ab` — the escaped character is dropped instead of + being returned as a literal. Every escape sequence read through getParam/find loses one + character and mis-positions the stream by one. +- **Verification:** Byte trace: `\` at i → seek(1,'c') moves to i+2 → readn returns byte i+2, + pointer at i+3; byte i+1 (the escaped character) is never returned. Contrast with + dblob._tostring's correct handling of the same convention (`c = myblob[i+1]; i += 1`, + lines 319–321), which returns the escaped character — the two implementations of one escape + convention disagree, and _tostring's is the sane one. + +### Line 69 — getParam2 starts reading inside the pattern: after find() the pointer is one past the pattern's *first* character, not behind the pattern (new finding) + +- **Anchor:** `myblob.seek(start, 'c') // move start forward` +- **Severity:** P1 +- **Failure scenario:** find()'s string path consumes only the first character of the match + (the inner integer-path `readNext`) and verifies the rest by indexing — so on return the + stream stands at `first + 1`. getParam2's comment ("Check if present and move pointer behind + pattern", line 68) is wrong, and the default `start = 1` lands the read at `first + 2`, i.e. + still inside the pattern for anything longer than two characters. + `getParam2("Env Zone 63", null, 2)` (the persistence unit's actual call shape) returns + `" Zone 63 …"` — pattern tail plus value — instead of the value. +- **Verification:** Pointer position traced through find(): integer sub-find returns + `myblob.tell() - 1` with the pointer after the matched byte (line 150); CheckIfSubstring moves + nothing (pure indexing); the string path returns without further reads (line 168). No caller in + the repo compensates with a pattern-length offset (both real call sites pass `start = 2` against + an 11-character pattern). The fix belongs in getParam2 (seek `pattern.len() - 1 + start`), not + in the callers. + +### Line 277 — dblob's file-constructor calls str.close() on dfile inputs, which dfile does not have — its _get metamethod throws (new finding) + +- **Anchor:** `str.close()` +- **Severity:** P2 +- **Failure scenario:** `dblob(someDfile)` routes into `case "file"` (dfile's `_typeof` returns + the wrapped type, line 174–175), copies the bytes via `str.myblob.readblob(...)`, then calls + `str.close()`. dfile defines no `close` method, so the lookup falls into `dfile._get` + (line 186), which recognizes only integer keys and `"myfile"` and ends at `throw null` — the + documented conversion (`dblob(fi|le)`, line 232) dies after doing the work. Raw-file inputs and + the toblob()/todblob() helpers (178/181) pass a real file and are unaffected. +- **Verification:** dfile's member list contains no close; class-instance slot misses route to + the class `_get` metamethod, whose fall-through is `throw null` (line 193). The + `instanceof ::dfile` branch directly above (271–272) shows the author handling dfile inputs + deliberately — the close call just wasn't given the same distinction. + +### Line 330 — dblob._add throws for string operands, breaking the documented `dblob("A") + "string"` (new finding) + +- **Anchor:** `myblob.writeblob(other instanceof ::blob? other : other.myblob) // distinguish between blob and dblob.` +- **Severity:** P2 +- **Failure scenario:** The class doc block promises `dblob("A") + "string" -> "Astring"` + (line 224). `_add` only distinguishes blob vs dblob; a string operand takes the `other.myblob` + branch and strings have no `myblob` slot — `the index 'myblob' does not exist`. The working + string append is `_mul` (line 334, via writec), so the `*` operator does what `+` documents. +- **Verification:** Direct read of _add; no string branch exists. The doc examples at 224–227 + distinguish `+ "string"` from `* "string"` only by speed ("This method is much faster!"), + confirming both were meant to work. + +### Line 134 — CheckIfSubstring reads past the end of the blob when a partial match sits at EOS (new finding) + +- **Anchor:** `if (myblob[tell() + i -1] != str[i]){` +- **Severity:** P2 +- **Failure scenario:** Blob-backed case this time: searching `"param"` in a blob that ends with + `"par"` — the integer sub-find matches `p` near the end, CheckIfSubstring then indexes + `myblob[len]` and beyond; the standard blob `_get` throws on an invalid index instead of + returning a mismatch. Any getParam over content whose tail coincides with a pattern prefix + crashes rather than returning the default. +- **Verification:** No bounds check between the sub-find and the index loop (`i` runs to + `str.len()-1` unconditionally); sqstdblob validates indices and raises. Distinct from the + file-indexing finding above: that one is about the *class* lacking `_get`, this one about + missing *bounds* even where `_get` exists. + +### Line 387 — dCSV's constructor forwards only the separator — the delimiter and commentstring arguments are accepted and silently ignored (new finding) + +- **Anchor:** `createCSVMatrix(separator)` +- **Severity:** P2 +- **Failure scenario:** `dCSV(file, true, '\t', '"', "#")` parses with the *defaults* + (`delimiter = '\''`, `commentstring = "//"`) because createCSVMatrix (line 458) declares its own + defaults and the constructor passes one argument. A CSV quoted with `"` or commented with `#` + parses wrongly with no error — quotes become literal cell content, comment lines become data. +- **Verification:** Constructor signature (368) takes all five; line 387 forwards one. Note also + the parameter-order trap between the two signatures: constructor order is + `(separator, delimiter, commentstring)`, createCSVMatrix order is + `(separator, commentstring, delimiter)` — even a naive "forward them all" fix would swap + delimiter and comment unless the orders are unified. + +### Line 542 — dCSV.refresh appends the whole file onto the existing matrix (rows duplicate) and drops its delimiter argument into the wrong parameter (new finding) + +- **Anchor:** `createCSVMatrix(separator, commentstring)` +- **Severity:** P2 +- **Failure scenario:** createCSVMatrix appends into `lines` (line 526) and never clears it, so + each refresh() doubles the row set (and useRowKey is rebuilt over the doubled rows, masking the + duplication for keyed access while `lines`/index access sees it). Additionally refresh's own + signature is `(separator, delimiter, commentstring)` but it forwards + `(separator, commentstring)` — its second parameter is ignored and the caller's commentstring + lands in createCSVMatrix's commentstring slot only by accident of the mismatched orders. +- **Verification:** `lines = []` happens once, in the constructor (386). refresh (539–543) resets + nothing. Parameter orders read off both signatures directly (same mismatch family as the + constructor finding above). + +### Line 471 — dCSV's cell loop calls readn at EOS when the file ends with a separator, throwing instead of finishing the parse (new finding) + +- **Anchor:** `local c = myblob.readn('c')` +- **Severity:** P2 +- **Failure scenario:** After consuming a separator (486–494) the loop `continue`s with + `lineraw == ""`; if that separator was the file's last byte, the EOS check at line 520 does not + fire (it requires `lineraw != ""`) and the next iteration's `readn` at EOS raises a stream + error. A trailing tab (or configured separator) at end-of-file — a normal artifact of + spreadsheet exports — aborts createCSVMatrix and therefore the constructor. +- **Verification:** Traced the only loop exits: separator branch (continue, no EOS check), + newline branch (break), comment branch (break), EOS-with-content branch (break, gated on + non-empty lineraw). The empty-lineraw-at-EOS state has no exit before the read. + +### Line 410 — dCSV._get's A1-notation probe indexes key[1] without a length check, throwing for any 1-character non-row key (new finding) + +- **Anchor:** `if (key[0] < 91 && key[1] < 58)` +- **Severity:** P3 +- **Failure scenario:** A lookup like `csv["A"]` (or any single-character key that is not in + useRowKey) reaches the A1 heuristic and `key[1]` throws index out of range instead of falling + through to the intended `throw null` miss signal — the error type callers might catch changes, + and iteration/`in` probes over the instance can crash. +- **Verification:** No `key.len()` guard; string indexing past the end raises in Squirrel. The + branch is otherwise best-effort heuristic (`key[0] < 91` also admits every digit and most + punctuation), which is why this is graded polish rather than P2. + +## Cleanup items (5) + +- **Line 285** (`str.tointeger()` in the float constructor case): result discarded — the + statement is a no-op and the float falls through to the integer case, where stream `writen` + coerces anyway; either assign it or delete the case comment pretending it converts. +- **Line 31** (`} catch(notfound) {` in dfile's constructor): the error path logs and `return`s, + leaving a half-constructed instance with `myblob = null` that throws opaquely on first use; + rethrowing (as dCSV does at 379) would fail at the informative point. +- **Line 51** (`if (find(separator, valid)){`): position 0 is falsy — a separator found at + offset 0 reads as "not found". Reachable only for `param == ""` (find's empty-pattern sentinel + returns 0), but it is the same `find()`-truthiness gotcha class as tracked T-23; use an explicit + `!= null` comparison. +- **Line 459** (`print("separator is " + separator.tochar())`): unconditional dev print on every + dCSV construction — same leftover family as T-60/T-63. +- **Line 174** (`function _typeof() return typeof myblob`): makes `typeof` on a dfile/dblob + report `"file"`/`"blob"` — deliberate (the constructor's dispatch depends on it, line 269) but + worth a loud comment; it also makes `instanceof` the only reliable type test for users, and is + the reason the `str.close()` bug above routes dfiles into the file case at all. + +## Incomplete items (3) + +- **Line 14** (`#NOTE IMPORTANT! Getting parameters over line breaks might not work.`): the CRLF + `+1`-per-line problem, author unsure whether fixed — tracked T-76. +- **Line 141** (`function find(pattern, start = 0, stopString = null){ // stopCharacter could be used as a hard terminator beside EOS`): + the stopString mechanism is half-built — it returns `false` (line 153), a value every caller + comparing `>= 0` would trip over as a bool-vs-int comparison error, and no repo code uses it + yet; finish it or remove it before someone does. +- **Line 317** (`for (local i = 0; i < myblob.len(); i++){ // TODO test, readn method or internal tostring again.`): + _tostring carries its own unresolved implementation TODO (and throws on a trailing lone `\`, + since `myblob[i+1]` overruns — same missing-bounds family as the CheckIfSubstring finding). + +## Suggestions (4) + +- **Make CheckIfSubstring length-guard first (`if (tell() + str.len() - 1 > len()) return false`) + and read via a small readblob instead of `[]`** — one change fixes both the file-indexing P1 and + the EOS overrun P2, because readblob exists on files and blobs alike. +- **Fix find()'s single-char branch to `return find(pattern[0], myblob.tell(), stopString)`** — + one token was lost; the comment above it already says what it should do. +- **Unify the (separator, delimiter, commentstring) parameter order across dCSV's constructor, + createCSVMatrix and refresh, and forward all of them** — the current three orders differ + pairwise, which is how both dCSV bugs got in. +- **Align readNext's escape semantics with _tostring's (return the escaped character)** — and add + a regression note to the class doc block, since getParam output for escaped content silently + changes with the fix. diff --git a/docs/review/wave2/fileblob-persistence.md b/docs/review/wave2/fileblob-persistence.md new file mode 100644 index 0000000..80451b4 --- /dev/null +++ b/docs/review/wave2/fileblob-persistence.md @@ -0,0 +1,235 @@ +# File&Blob — Persistence (DPersistentSaveSimple, cDCustomHandler, cDSaveHandler, DPersistentSave) + +**File:** `DScript File&Blob.nut` · **Anchor:** line 567 (`class DPersistentSaveSimple`), line 630 (`class cDSaveHandler`), line 822 (`class DPersistentSave`) + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +**Method note:** This unit was reviewed directly by the session model (Fable) after the subagent +review failed twice on API 529 overload. Review and verification were a single pass by a single +model — each "Verification" bullet is a self-check trace, not an independent adversarial pass. +File is ISO-8859-1 (grep needs `-a`). + +## Overall assessment + +Both persistence mechanisms are non-functional end to end by static trace. DPersistentSaveSimple +(flag-file per event) fails at three independent links: its timestamp is written under a different +QVar key than it is read, its read path and write path derive the filename under two *different* +parameter names so the default configuration never matches its own files, and its +message relay calls a method that does not exist on DRelayTrap. DPersistentSave/cDSaveHandler (the +env-zone-slot mechanism) is worse: the initial `taglist_vals.txt` scan dies on the file-indexing +defect inherited from dfile, the slot parser reads from inside its own search pattern, GetEvent +indexes a string with a negative subscript (unsupported — throws every call), and the fresh-mission +data path hands a raw `::blob()` to string-slicing code. Even if each link were fixed, SetEvent's +value encoding writes two characters for values ≥ 10 into a layout GetEvent reads one character at +a time. cDCustomHandler itself is a reasonable non-SqRootScript handler pattern. Tracked row T-77 +(backup blob, hex conversion TODOs) re-confirmed; the dead HexCharToInt helper is its artifact. + +## Confirmed bugs (13) + +### Line 574 — Timestamp is written under "Timestamp" but checked and read under "DTimestamp" — the new-game stamp never exists where it is looked for (new finding) + +- **Anchor:** `Quest.Set("Timestamp", (date().yday<<11)+(date().hour<<6)+(date().min))` +- **Severity:** P1 +- **Failure scenario:** OnBeginScript guards on `!Quest.Exists("DTimestamp")` (573) and the + filename builder reads `Quest.Get("DTimestamp")` (580) — but the Set at 574 writes + `"Timestamp"`. Unless cDSaveHandler happens to be constructed too (it sets the correct key, + line 642), `Quest.Get("DTimestamp")` returns 0 forever: the Exists guard never becomes true (the + stamp is re-written every BeginScript) and every event filename collapses to `Event_0.dsav`, + defeating the per-playthrough separation the timestamp exists for. +- **Verification:** Three key usages read directly off lines 573/574/580; cDSaveHandler's 641–642 + pair shows the intended spelling. No other writer of "DTimestamp" exists in this file, and + DPersistentSaveSimple does not construct cDSaveHandler. + +### Line 597 — Read path and write path use different parameter names (ClearAtNewGame vs AllowNewGame), so the default configuration checks for a file it never writes (new finding) + +- **Anchor:** `if (DGetParam(_script + "AllowNewGame")){` +- **Severity:** P1 +- **Failure scenario:** OnBeginScript decides between `Event_.dsav` and `Event.dsav` using + `ClearAtNewGame` (default **true**, lines 579–583); DoOn decides the same thing using + `AllowNewGame` (default **null**, line 597). With neither parameter set — the plain drop-in + case — DoOn writes `Event.dsav` while OnBeginScript looks for `Event_XXXX.dsav`: the persisted + event is never detected. The two names must be one parameter (or deliberately documented as a + pair, which the defaults still contradict). +- **Verification:** Both call sites read directly; grep confirms `AllowNewGame` appears nowhere + else in the repo and `ClearAtNewGame` only in this class — neither is documented, so no design + note convention rescues the defaults. + +### Line 589 — base.RelayMessages does not exist — DRelayTrap's method is DRelayMessages; both persistence classes throw at their relay moment (new finding) + +- **Anchor:** `base.RelayMessages("On")` +- **Severity:** P1 +- **Failure scenario:** DRelayTrap defines `DRelayMessages` (Core:2119); there is no + `RelayMessages` anywhere in the hierarchy. DPersistentSaveSimple:589 (fires when the flag file + is found at mission start — the class's entire payoff) and DPersistentSave:861 (the DataMatch + relay path) both throw `the index 'RelayMessages' does not exist` at exactly the moment the + persisted event should be delivered. Line 865 spells it correctly, confirming the intended name. +- **Verification:** `grep -an "function DRelayMessages" "DScript Core.nut"` → 2119; no + `function RelayMessages` exists. Both bad call sites and the one good one (865) read directly. + +### Line 720 — GetSaveRaw's marker scan runs multi-character find() on a file-backed dfile, which throws on the stream-indexing defect — the handler dies during initialization (new finding) + +- **Anchor:** `rawdata = File.slice(File.find(eDLoad.kStart), File.find(eDLoad.kEnd)) // blobs are way faster than doing this in the stream. More memory though.` +- **Severity:** P1 +- **Failure scenario:** `File` is `::dfile("taglist_vals.txt")` (719); `find("ENVMAPVAR")` + reaches `CheckIfSubstring`, which indexes the underlying standard file object — unsupported, see + the fileblob-dfile report (line 134 finding). GetSaveRaw is called from RegisterPrint (667) + during handler construction, so the entire DPersistentSave system throws before reading a single + slot. Secondary: if either marker were genuinely absent, `find` returns null and + `File.slice(null, …)` would throw anyway — there is no missing-marker handling. +- **Verification:** Cross-reference to the verified dfile finding; call chain + DPersistentSave.OnBeginScript → cDSaveHandler() → base handshake → DoAfterRegistration → + RegisterPrint → GetSaveRaw traced through lines 832–835, 620–626, 646–652, 654–667. + +### Line 723 — The slot scan's getParam2 call starts two bytes past the pattern's first character, so it reads the pattern's own tail and never sees "" or a "$" prefix — slot and save detection is dead (new finding) + +- **Anchor:** `local param = rawdata.getParam2("Env Zone "+i, null, 2)` +- **Severity:** P1 +- **Failure scenario:** Per the getParam2 finding (fileblob-dfile report): after find() the + pointer stands at `first + 1`; `start = 2` seeks to `first + 3`, i.e. into `"Env Zone 63"` + itself. The returned string is `" Zone 63 "` — never the empty string + (line 725's free-slot test) and never `'$'`-prefixed (line 729's save-data test). No slot is + ever recognized as free or as a save; SaveFile's backup scan (line 780, same call shape) + misreads identically. +- **Verification:** Pointer arithmetic verified in the dfile report; both call sites pass the + same `start = 2` against the 11-character pattern. Even with getParam2 fixed, note the format + dependency: the value must follow the pattern with exactly the separator the caller's `start` + assumes — worth an explicit format comment in eDLoad. + +### Line 729 — param can be null (getParam2 default when the slot line is absent) and is indexed without a guard (new finding) + +- **Anchor:** `} else if (param[0] == '$'){ // Save data from other missions.` +- **Severity:** P2 +- **Failure scenario:** getParam2 is called with `def = null` (723); a `taglist_vals.txt` that + simply lacks an `Env Zone ` line for one of the eight scanned slots returns null, which + passes the `param == ""` test (725) as false and throws on `param[0]`. Engine dumps are not + guaranteed to enumerate all 64 zones. +- **Verification:** getParam2's default-return path (line 81) returns `def` verbatim; the only + guards at 725/729 test `""` and index — null falls through to the index. Squirrel null has no + `[0]`. + +### Line 743 — Free-slot search tests string keys against a table keyed by integers — never matches, always "finds" a slot (new finding) + +- **Anchor:** `if (!(i.tostring() in Saves)){` +- **Severity:** P2 +- **Failure scenario:** Saves is populated with integer keys (`Saves[i] <- param`, 734; + `temp[i - 1] <- …`, 756/764). `i.tostring() in Saves` is therefore false for every i, so the + fallback loop believes all slots 56–63 are unused and settles on slot 56 (last assignment in + the downward loop), silently overwriting whatever the oldest slot held instead of detecting the + genuinely free one. +- **Verification:** Squirrel table keys are typed; `"63" in {63: x}` is false. All insertion + sites in this function use integer keys — read directly at 734/756/764/770. + +### Line 795 — SetEvent's splice is wrong three ways: fresh missions hold a raw ::blob() with no slice, event_id = 1 duplicates the whole record, and values ≥ 10 write two characters into a one-character-per-event layout (new finding) + +- **Anchor:** `MissData = MissData.slice(0, -event_id) + value + MissData.slice(-event_id + 1)` +- **Severity:** P1 +- **Failure scenario:** (a) On a mission with no prior save, GetSaveRaw sets + `MissData = ::blob()` (769); the standard blob class has no `slice` method, so the very first + SetEvent throws. (b) When MissData is a string and `event_id == 1`, the tail slice is + `slice(-1 + 1)` = `slice(0)` — the *entire* string — so the record becomes + `prefix + value + whole-old-record`. (c) `value` is asserted into 0…15 (793) and concatenated + in decimal: 10…15 insert two characters, shifting every other event's position; GetEvent (813) + reads exactly one character per event and expects hex. +- **Verification:** (a) sqstdblob's method set read against the call; the fresh-blob path at + 768–770 assigns before any type normalization. (b) `-event_id + 1` evaluated for id 1; + Squirrel `slice(0)` returns the full string. (c) assert bound at 793 vs the single-character + read at 813 and the hex decode at 814 (`CompileExpressions("0x", MissData[-e].tochar())`). + +### Line 813 — GetEvent indexes the record with a negative subscript, which Squirrel strings and blobs both reject — every call throws (new finding) + +- **Anchor:** `if (MissData[- event_id] != '-')` +- **Severity:** P1 +- **Failure scenario:** Negative indices are a `slice()` feature; the `[]` accessor on strings + and standard blobs requires 0 ≤ idx < len and raises otherwise. GetEvent is called from + DPersistentSave.OnSim (852) on every mission start once EventID is configured — the class's + read path throws unconditionally, before any of the relay logic runs. +- **Verification:** Squirrel string/blob `_get` semantics; contrast with the deliberate + negative-`slice` usage two lines above at 795, which is legal. No wrapper (dblob) is in play: + MissData is a string (from getParam2) or ::blob() (769). + +### Line 877 — DPersistentSave.DoOn assigns to the undeclared `event_name` — throws whenever AllowNewGame is set, and is dead code besides (new finding) + +- **Anchor:** `event_name = ::format("%s_%s", event_name, Quest.Get("DTimestamp"))` +- **Severity:** P2 +- **Failure scenario:** No `local event_name` exists in DoOn (only `EventID`, 875) and the class + has no such member — in Squirrel, assigning to an undefined slot on a class instance raises + (instances do not support the new-slot operator). The line also *reads* `event_name` before + assigning it, and its result is never used: SetEvent (879) takes only EventID and Data. The + block is a leftover from DPersistentSaveSimple.DoOn (596–598) that no longer has a purpose. +- **Verification:** Scope read directly (874–880); DPersistentSaveSimple's parallel block names + the same variable, confirming the copy origin. Squirrel instance-slot semantics per the + language reference. + +### Line 864 — The non-DataMatch relay is gated on `if (event_data)`, so the "Off" arm of its own ternary is unreachable — a stored 0 never relays anything (new finding) + +- **Anchor:** `if (event_data)` +- **Severity:** P2 +- **Failure scenario:** `base.DRelayMessages(event_data? "On" : "Off", …)` (865) plainly intends + 0 → "Off"; the guard one line up filters 0 out first. Combined with the comment ("Just + differentiate between TRUE > 0 and FALSE == 0") the guard inverts the design: missions cannot + react to a persisted "off" state. +- **Verification:** Direct read; `event_data` is an int 0–15 here (856 gate allows 0 through — + `0 >= 0`), so the inner ternary's Off branch is reachable only if the outer guard is removed. + +### Line 699 — GetMissionPrint slices the map name with fixed negative offsets, throwing for short or unset .mis names, and disagrees with its own comment (new finding) + +- **Anchor:** `stamp += map.slice(-6,-4) // last 4 are '.mis'` +- **Severity:** P3 +- **Failure scenario:** `slice(-6,-4)` yields two characters (the comment above says "Last 3 + character of miss file", the summary at 709 says "3 mis characters" — the checksum layout + documented there is off by one). For a map name shorter than 6 characters (`"x.mis"`, or the + empty string when no mission is loaded in a fresh DromEd session — the guard at 689 covers only + `name`, not `map`), the slice raises. The auto fingerprint also emits bytes up to 158 + (`key % 126 + 33`, line 704) — non-ASCII characters written into and re-parsed from + `taglist_vals.txt`. +- **Verification:** Slice arithmetic and both comments read directly; the editor path traced + through 689–699 — `map` is used unconditionally while `name` has the guard. + +### Line 861 — second site of the RelayMessages misname, in DPersistentSave.OnSim's DataMatch branch (new finding) + +- **Anchor:** `base.RelayMessages("On", userparams(), _script, event_data)` +- **Severity:** P1 +- **Failure scenario:** Same defect as line 589 (see above): the method is `DRelayMessages`. Any + object using the documented `DataMatch` comparison parameter throws at the moment the match + succeeds. Line 865, four lines below, uses the correct name. +- **Verification:** As for line 589; listed separately because the two sites live in different + classes and will be fixed in different functions. + +## Cleanup items (5) + +- **Line 587/684/685/741/750/781/794/796/854** (`print(IsOn)` …): nine unconditional dev prints + across the unit — same leftover family as T-60/T-62/T-63; GetSaveRaw and SetEvent print raw + save data on every call. +- **Line 737** (`if (slot || Saves.len() <= 8){`): the else branch (760–767) is unreachable — + Saves can hold at most the 8 scanned slots, so `len() <= 8` is always true; the "remove older + saves" logic it contains has never run. +- **Line 802** (`function HexCharToInt(c){`): dead helper — GetEvent uses CompileExpressions + instead; both are flagged by tracked T-77's hex-conversion TODO. Keep exactly one mechanism. +- **Line 615** (`if (!("ClassName" in this))`): GetClassName's error() is immediately followed by + `return ClassName`, which throws the very slot-miss the error message was meant to soften; + return a sentinel or throw the message itself. +- **Line 591** (`if (RepeatForCopies(callee()))`): bare `callee()` (works via root fallback) where + the rest of the codebase writes `::callee()`; unify to keep grep-ability of the T-30/T-31 + defect class. + +## Incomplete items (2) + +- **Line 797** (`// TODO also do a backup blob`): SetEvent's backup path — tracked T-77. +- **Line 108 (DSConfigDefault.nut)** (`kFile = "taglist_vals.txt"// File to read. // TODO: Shock compatible?`): + the whole mechanism's data source is flagged as unverified for SS2 — tracked T-78's + `taglist_vals.txt` question; the persistence feature inherits it wholesale. + +## Suggestions (4) + +- **Fix the four fatal links in dependency order** — file indexing (dfile report), then getParam2 + pointer, then GetEvent's `[-e]` → `slice(-e, -e+1)[0]` (or keep MissData as a dblob and use its + negative-capable `_get`… which std blobs lack too — slice is the safe form), then the + RelayMessages renames — anything else in this unit is untestable until those four pass. +- **Normalize MissData to one type at the GetSaveRaw boundary** (always a string, padded to + kDataLength with `'-'`) — removes the ::blob() branch, makes SetEvent's splice well-defined, and + gives event_id=1 a real one-character tail to replace. +- **Store event values as single hex characters** (`::format("%X", value)`) — matches GetEvent's + hex decode and keeps the fixed-width layout the slot format requires. +- **Collapse ClearAtNewGame/AllowNewGame into one documented parameter** read identically by + OnBeginScript and DoOn — and let cDSaveHandler be the only writer of "DTimestamp" so the two + classes cannot disagree about the stamp again. diff --git a/docs/review/wave2/general-buttons-hitscan.md b/docs/review/wave2/general-buttons-hitscan.md new file mode 100644 index 0000000..3750a06 --- /dev/null +++ b/docs/review/wave2/general-buttons-hitscan.md @@ -0,0 +1,168 @@ +# SafeDevice + DStdButton + DHitScanTrap + +**File:** `DScript General.nut` · **Anchor:** line 1 (top-level/header, `SafeDevice` :3-18, `DStdButton` :21-104, `DHitScanTrap` :108-230) + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +The three classes in this unit are at very different maturity levels. `SafeDevice` (:3-18) is a 6-line +`SqRootScript` helper that does what it says for the common lever case, with two unguarded edges (any +tweq type clears the frob lock; a tweq that never completes locks the object out permanently). +`DStdButton` (:21-104) is close to working — it reimplements StdButton's frob/collision/TrapControlFlags +behaviour on top of `DRelayTrap` and correctly chains `base.OnBeginScript()` — but it applies +`TRAPF_INVERT` *after* testing `TRAPF_NOON`/`TRAPF_NOOFF` (so those two flags are effectively +unimplemented), never sets `SourceObj`, silently skips the universal `Condition` parameter that +`DBaseFunction` would have honoured, drops `base.OnEndScript()` (so `DHandler` registrations leak), +and calls the Thief-only `DarkGame.FoundObject` unconditionally. `DHitScanTrap` (:108-230) is the +weakest code in the unit and is **dead on arrival in its documented default configuration**: three +separate P1 throws sit on the main path — the empty-string `TOnResult`/`TOffResult` defaults reach +`DCheckString`'s unguarded `str[0]`, `Camera.CameraToWorld(50,0,0)` passes three floats to a +one-vector native, and `DoOff()` is declared with zero parameters while the framework unconditionally +calls `DoOff(DN)` — on top of the already-tracked `vrom`/`vfrom` typo (T-15), which turns out to crash +rather than merely lose a value. Beyond the crashes, the class relies on class-level `hobj`/`hloc` +defaults that are shared across all instances and sends its hit message unconditionally from that +stale storage, reuses the full `DCheckParameters` pipeline as an "AutoOff" side-channel (burning +Count/Capacitor charges), and passes the string `"On"`/`"Off"` as `ScriptAction`, which defeats every +action-dependent branch in `DCheckParameters`. The wave-1 `DTrigger` defects surface here as expected: +`TDelay`/`TCapacitorFalloff` never resolve, so the `DoOff()` cleanup at :219-226 guards a mechanism +that cannot currently fire, and the T-namespace `Count`/`Capacitor` data slots are never even created +because `DTrigger.RepeatForCopies`'s second pass no-ops without a `…TCopies` parameter. + +## Confirmed bugs (15) + +### Line 215 — `DHitScanTrap.DoOff()` is declared with zero parameters while the framework always calls `DoOff(DN)`, so Squirrel raises "wrong number of parameters" on every TurnOff. (new finding) + +- **Anchor:** `function DoOff()` +- **Severity:** P1 +- **Failure scenario:** A `DHitScanTrap` object receives its default Off message `TurnOff`. `DBaseFunction` reaches `if (DCheckParameters(DN, kScriptTurnOff)) DoOff(DN)` (Core.nut:1816-1817) and calls the override at General.nut:215 with one argument. Squirrel enforces exact arity for non-vararg, non-default-parameter closures, so the call throws instead of running the body — meaning the one thing this override exists for (stopping a `T`-mode infinite repeat and de-registering the per-frame update at :219-226) never happens, and the whole message dispatch for that object aborts mid-flight (any later `_script`/BlockMessage bookkeeping in `DBaseFunction` is skipped). The same throw occurs from `OnTimer`'s delayed-Off path (`return DoOff(userparams())`, Core.nut:1761). Every other `DoOn`/`DoOff` override in the active V2 files takes `DN` (or `DN = null`); this is the only zero-arity `DoOff` outside the editor-only tooling file. +- **Verification:** Confirmed. `DBaseFunction` calls `DoOff(DN)` with one argument (Core:1816-1817) and OnTimer's delayed path calls `DoOff(userparams())` (Core:1762), while General:215 declares `function DoOff()` with zero parameters and no defaults/varargs — Squirrel rejects surplus arguments to script closures. The path is live: DHitScanTrap declares no `DefOff`, so it inherits the default "TurnOff" routing (Core:1813). + +### Line 191 — `TOnResult`/`TOffResult` default to the empty string, and `DGetParam` routes defaults through `DCheckString`, whose operator switch indexes `str[0]` — so `DoOn` throws for every `DHitScanTrap` that does not explicitly set *both* parameters. (new finding) + +- **Anchor:** `DGetParam(_script + "TOnResult","",DN)` +- **Severity:** P1 +- **Failure scenario:** An author places `DHitScanTrap` on a marker with `DHitScanTrapFrom`/`To` set and nothing else — the configuration the class docstring describes ("By default when any object is hit a TurnOn will be sent to CD Linked objects"). `DoOn` runs the raycast, then evaluates `DGetParam(_script + "TOnResult", "", DN)`. The key is absent from the Design Note, so `DGetParam` (Core.nut:1499-1507) returns `DCheckString("", false)`; `typeof("")` is `"string"`, which is not one of the early-returned types (Core.nut:1012-1023 — the author's `case ""` guard at 1025-1031 is commented out), so execution reaches `switch (str[kGetFirstChar])` at Core.nut:1033, i.e. `""[0]`, an out-of-range string index that Squirrel raises as a runtime error. Line 199 has the identical `""` default for `TOffResult`, so even setting `TOnResult` only defers the crash to the first scan whose result does not match. Net effect: the class cannot fire at all without two otherwise-optional, undocumented parameters, and the raycast's `RenderType` juggling (:178) is left un-restored because the throw happens before :208. Note: wave-1 `dbasics.md` reports the missing guard on the Core side (Core.nut:1033) and already cites these two lines as its live call sites — this entry is the same defect seen from the caller, where the `""` default could equally be fixed. +- **Verification:** Confirmed. Absent key → `DGetParam` returns `DCheckString("", false)` (Core:1504-1506); `typeof("")` is `"string"`, so it passes the early-return type switch (Core:1012-1024; the author's `case ""` guard at Core:1025-1031 is commented out) and reaches `switch (str[kGetFirstChar])` at Core:1033 with `kGetFirstChar = 0` (DSConfigDefault:93) — an out-of-range index on the empty string, a Squirrel runtime error. The identical `""` default sits at General:199, and in the default configuration (From/To defaulting to `self`, General:149-150) execution demonstrably reaches :191, so the crash precedes the RenderType restore at :208-211. + +### Line 166 — `::Camera.CameraToWorld(50,0,0)` passes three floats to a native that takes a single `vector`, so the documented "From player and To player ⇒ beam centred on the player's view" mode throws. (new finding) + +- **Anchor:** `::Camera.CameraToWorld(50,0,0)` +- **Severity:** P1 +- **Failure scenario:** The docstring (:114-116) advertises: "If the From object is the player the camera position is used; if the To object is also the player the beam will be centered at the players view". An author sets `DHitScanTrapFrom="[player]";DHitScanTrapTo="[player]"` and triggers the trap. Line 165's condition is satisfied, and line 166 calls `Camera.CameraToWorld(50, 0, 0)`. Per `DOC/squirrel_script/Custom-API-reference_services.nut:445` the signature is `vector CameraToWorld(vector local_pos)` — one argument, and its own comment gives the intended call shape `CameraToWorld(vector(0,0,0))`. squirrel.osm's native closures declare a fixed parameter mask, so three arguments produce a "wrong number of parameters" error and `DoOn` aborts before the raycast. The intended code is `::Camera.CameraToWorld(vector(50,0,0))`. +- **Verification:** Confirmed. Signature checked at `Custom-API-reference_services.nut:444-445`: `vector CameraToWorld(vector local_pos)`, one parameter, with the intended call shape (`CameraToWorld(vector(0,0,0))`) given in the adjacent comment; General:166 passes three bare integers. Reachability verified: when `From` resolves to the player, the branch at General:155-157 sets `vfrom` but leaves `from` equal to `::PlayerID`, so the condition at :165 is satisfiable exactly as the docstring advertises. + +### Line 161 — the `vrom`/`vfrom` typo does not merely create a stray global: it leaves `vfrom` null, so a vector `From` makes `Engine.ObjRaycast(null, …)` throw rather than silently mis-aim. (tracked: T-15) + +- **Anchor:** `vrom = from` +- **Severity:** P1 +- **Failure scenario:** `DHitScanTrapFrom="<10,20,3>"` (the `` vector operator) makes `typeof from == "vector"`, so control enters the `else` at :160. `vrom = from` creates a root-table slot (Squirrel resolves the bare name against the root table on assignment) and `vfrom` keeps its `null` initialiser from :152. Line 181 then calls `Engine.ObjRaycast(vfrom, vto, …)` with `null` where the native expects a `vector`, which is a type error, not a silent no-op — so a vector `From` aborts `DoOn` entirely (including the `RenderType` restore at :208) instead of just scanning from the wrong place. T-15 already records the typo and the one-word fix; the manifestation worth recording is that this is a hard throw and that the sibling `to` branch at :169-172 is written correctly, which is the diff to copy from. +- **Verification:** Confirmed (tracked T-15), with one mechanism correction: Squirrel `=` cannot create slots (CLAUDE.md gotcha — `<-` creates, `=` requires the slot to exist), and `vrom` exists nowhere else in the repo (its only occurrence is General:161), so the assignment's member/root fallback fails and the throw ("the index 'vrom' does not exist") happens at :161 itself — execution never reaches `Engine.ObjRaycast` at :181, and no root-table slot is created. The net finding stands and is stronger: a vector `From` hard-aborts `DoOn` immediately (skipping the :208-211 restore); both this entry's "creates a root-table slot" aside and the T-15 row's "creates a global" wording are inaccurate on that detail. + +### Line 187 — the hit message is sent unconditionally, from a class-level `hobj` shared by every `DHitScanTrap` instance, so a scan that hits nothing or terrain messages whatever object some *earlier* scan hit. (new finding) + +- **Anchor:** `local hobjID = hobj.tointeger()` +- **Severity:** P2 +- **Failure scenario:** `hobj = object()` (:129) is a class member default; Squirrel copies the *reference* into each instance, so all `DHitScanTrap` objects in the mission share one `object` out-parameter, and `Engine.ObjRaycast` only writes into it for return codes 2 and 3 (object / mesh hit). Trap A scans and hits a crate (`hobj` = crate). Trap B, elsewhere in the level, scans across open air: the raycast returns 0, leaves `hobj` untouched, and lines 187-189 unconditionally do `DSendMessage(hobj.tointeger(), "DHitScan")` — so the crate receives a `DHitScan` that no beam touched. On the very first scan of the mission the same lines post `DHitScan` to object 0. The docstring says "The Object that was hit will receive the message specified by DHitScanTrapHitMsg", so the send belongs inside a result check (`result` is already computed at :181 for exactly that purpose, and `"34".find(result)` would express it), and `hobj`/`hloc` should be instance-local (nulled in a constructor) per the shared-mutable-default hazard in CLAUDE.md. +- **Verification:** Confirmed. `hobj = object()` (General:129) is a class-member default holding a reference-type engine out-param, shared across instances per the CLAUDE.md hazard; the documented ObjRaycast contract writes `hit_object` only for return types 2 and 3 (`Custom-API-reference_services.nut:116-117`), so a miss (0) or terrain hit (1) leaves the previous value — or the never-written ID 0 on the first scan — and the send at General:188-189 is unconditional. `DSendMessage` (Core:2079-2084) forwards straight to `PostMessage` with no target guard. + +### Line 210 — the ignore-set restore hard-codes `RenderType = 0`, so every object listed in `ignore_set` is permanently rewritten to "Normal" regardless of what it was. (new finding) + +- **Anchor:** `Property.SetSimple(obj,"RenderType", 0)` +- **Severity:** P2 +- **Failure scenario:** `DHitScanTrapignore_set` lists a decorative object whose Renderer→Render Type is `Unlit` (2) — or `EditorOnly` (3), or already `NotRendered` (1), or which inherits its Render Type from its archetype with no local property at all. Line 178 sets it to `1` so the raycast's rendered-only filter skips it (correct per `Custom-API-reference_services.nut:108-110`: flag bit 1 restricts the cast to Render Type Normal or Unlit), but line 210 restores the hard-coded value `0`. After a single scan the Unlit object is lit, the EditorOnly object is visible in game, and an object that only inherited a Render Type now carries a *local* `RenderType 0` that shadows its archetype forever. The original value must be read with `Property.Get` (or `PossessedSimple` + `Property.Remove`) before :178 and written back at :210. Compounding this, any throw between :178 and :208 — and there is a guaranteed one at :191 in the default configuration (see above) — leaves the whole ignore set stuck at `NotRendered`, i.e. invisible in game. +- **Verification:** Confirmed. General:177-178 writes `RenderType` 1 onto every ignore-set object without first reading (or checking possession of) the existing value, and General:208-210 restores a hard-coded 0 — the original value is never captured anywhere in `DoOn`, so any non-Normal original or inherited-only property is permanently rewritten. The rendered-only flag semantics match `Custom-API-reference_services.nut:108-110`, and the compounding throw between :178 and :208 in the default configuration is the verified Line 191 finding above. + +### Line 196 — `AutoOff` implements "stop the infinite repeat" by re-entering the *whole* `DCheckParameters` pipeline, so each scan additionally burns a Count/Capacitor charge and re-rolls `FailChance`. (new finding) + +- **Anchor:** `DCheckParameters(DN, kScriptTurnOff)` +- **Severity:** P2 +- **Failure scenario:** `DHitScanTrapCount="3";DHitScanTrapAutoOff="1"`. On each activation `DBaseFunction` calls `DCheckParameters(DN, kScriptTurnOn)`, which increments `_script+"Counter"`; `DoOn` then calls `DCheckParameters(DN, kScriptTurnOff)` at :196, which — because `CountOnly` defaults to `FALSE` and the guard is `if (CountOnly == FALSE || …)` (Core.nut:1948) — increments the same counter a second time. The trap therefore stops after 2 activations instead of 3. With `DHitScanTrapCapacitor="3"` the AutoOff call likewise charges the capacitor a second time per scan, and with `DHitScanTrapFailChance` set the AutoOff call can `return` early on its own random roll (Core.nut:1929-1931), silently skipping the infinite-repeat teardown it was added for. Additionally, with no `Delay` parameter at all the entire call is a no-op that only produces those side effects, because the InfRepeat teardown lives inside `if (delay)` (Core.nut:1970-2027). The intent ("This will disable an infinite repeating TurnOn") wants only the InfRepeat branch, not the Count/Capacitor/FailChance stages. Line 204 is the same call on the Off path. +- **Verification:** Confirmed against `DCheckParameters`: the counter increments whenever `CountOnly == FALSE` (Core:1946-1950), so after `DBaseFunction`'s own On-side call (Core:1807) the AutoOff re-entry at General:196/:204 charges the counter a second time per activation; the general capacitor (Core:1936) is likewise double-charged, a fresh FailChance roll (Core:1929-1931) can `return` before any teardown, and with no `Delay` parameter the InfRepeat/per-frame teardown is unreachable because it all lives inside `if (delay)` (Core:1969-2027). + +### Line 194 — `TriggerMessages` is handed the string `"On"`/`"Off"` as `ScriptAction`, which is forwarded verbatim into `DCheckParameters`, where every action-dependent test compares it against the integers `kScriptTurnOn`/`kScriptTurnOff`. (new finding) + +- **Anchor:** `TriggerMessages("On", DN)` +- **Severity:** P2 +- **Failure scenario:** `DHitScanTrapTDelay="2F"` (per-frame T-mode repeat) plus a scan result that matches `TOffResult`. `TriggerMessages("Off", DN)` (:202) leaves `ScriptAction` as the string `"Off"` (Core.nut:2173-2178 only converts when `typeof ScriptAction != "string"`) and calls `DCheckParameters(DN, "Off")`. In the per-frame teardown branch the test is `if (!ScriptAction)` (Core.nut:1993, inside the InfRepeat block at 1988-2007): a non-empty string is truthy in Squirrel, so `!"Off"` is false and `PerFrame_DeRegister`/`ClearData` never run — the per-frame T repeat can never be turned off. In the same function `ScriptAction == kScriptTurnOn` / `== kScriptTurnOff` (Core.nut:1937-1938) can never be true for a string, so `T`-side `OnCapacitor`/`OffCapacitor` are unreachable; `CountOnly + ScriptAction == 2` (Core.nut:1948) becomes string concatenation (`1 + "On"` → `"1On"`), so `TCountOnly` never restricts anything; and `DSetTimerData(_script+"Delayed", delay, ScriptAction, …)` (Core.nut:2025) stores `"On"`, which `OnTimer` later reads back as `ar[0].tointeger()` → `0` → an Off action. The call sites should pass `kScriptTurnOn`/`kScriptTurnOff` (which `TriggerMessages` already converts to `"On"`/`"Off"` for `DRelayMessages`), or `TriggerMessages` should normalise the string to the integer before calling `DCheckParameters`. Wave 1 documented that T-mode *timing* never resolves; this is a separate, additive defect in what gets passed down, and it is the reason the per-frame T variant cannot be stopped even though `DoOff` (:219-226) was written to stop it. +- **Verification:** Confirmed on the load-bearing mechanisms: `TriggerMessages` (Core:2171-2182) leaves a string `ScriptAction` untouched (the conversion at Core:2173-2177 fires only for non-strings) and hands it to `DCheckParameters`; there `if (!ScriptAction)` (Core:1993) is false for the truthy string "Off" so `PerFrame_DeRegister` is unreachable, `ScriptAction == kScriptTurnOn/Off` (Core:1937-1938; constants 1/0 at Core:113-114) never matches a string, and `CountOnly + ScriptAction == 2` (Core:1948) becomes string concatenation. One detail corrected: the `"On"` stored via `DSetTimerData` (Core:2025) is never actually read back as `0` — the T-suffixed timer name never matches `OnTimer`'s `_script+"Delayed"` test (Core:1744), and if it were reached `"On".tointeger()` (Core:1746) would throw rather than yield 0 — downstream of the same handoff defect, so the finding stands. + +### Line 193 — the `Triggers` whitelist compares an integer ObjID against entries that `DCheckString` leaves as plain name strings, so the documented "only these objects trigger" filter silently never matches. (new finding) + +- **Anchor:** `triggers[0]==null || triggers.find(hobjID) != null` +- **Severity:** P2 +- **Failure scenario:** The docstring (:122-123) offers "if just a special set of objects should trigger a TurnOn then these can be specified via DHitScanTrapTriggers". An author writes the natural `DHitScanTrapTriggers="+SecretCrate+SecretUrn"`. `DCheckString`'s `+` case (Core.nut:1355-1373) recursively resolves each token, and a bare object name matches none of the operator cases and no numeric regexp, so it falls out of the switch unchanged as the *string* `"SecretCrate"` (Core.nut:1487). Line 193 then evaluates `triggers.find(hobjID)` where `hobjID` is an integer (`hobj.tointeger()`, :187); Squirrel's `array.find` uses value equality across differing types, so it returns `null` for every entry and the trap never fires even when the beam hits exactly that crate — with no error to diagnose it. Only ID-producing expressions (`&ControlDevice`, `[player]`, `$QVar`, a literal ID) work today. The fix is to normalise both sides (e.g. resolve each entry with `ObjID`/`Object.Named`, or compare via `Object.InheritsFrom` to also support the archetype case the docstring's "special set of objects" wording invites). Line 201 is the same code on the Off path. +- **Verification:** Confirmed. `DCheckString` returns bare-name tokens unchanged as strings — the `+` case (Core:1355-1373) just recurses per token, and a name matches no operator case, no numeric regexp (Core:1479-1483), and falls out at the final return (Core:1487). `triggers.find(hobjID)` at General:193/:201 then compares string entries against the integer from `hobj.tointeger()` (General:187); Squirrel equality never equates values of different types (int/float aside), so the filter can only ever match ID-producing expressions. + +### Line 73 — `TRAPF_NOON`/`TRAPF_NOOFF` are tested *before* `TRAPF_INVERT` is applied, so both flags are effectively unimplemented on `DStdButton`. (new finding) + +- **Anchor:** `if((on && !(trapflags & TRAPF_NOON))` +- **Severity:** P2 +- **Failure scenario:** An author sets TrapControlFlags = `Invert | NoOff` on a `DStdButton` — the standard idiom for "this button should do nothing" or, more usefully, `Invert | NoOn` for "this button only ever sends the Off action". With `Invert|NoOff`: `on` is hard-coded `true` at :65 and never reassigned before the test, so `(on && !(trapflags & TRAPF_NOON))` is true, the block is entered, :75-76 flips `on` to `false`, and :82-83 runs the *Off* action — exactly the action `NoOff` forbids. With `Invert|NoOn`: the first disjunct is false and the second (`!on && …`) is dead because `on` is still `true` at that point, so nothing fires at all, when the intended behaviour is to send Off. The standard order is to apply `INVERT` first and only then filter with `NOON`/`NOOFF`. Because `on` is a constant `true` on entry, the entire `!on` half of the condition at :74 is unreachable dead code, which is what hides the ordering mistake. +- **Verification:** Confirmed. `on` is assigned `true` at General:65 and not touched until the test at :73-74, so the `!on` disjunct is dead; `TRAPF_INVERT` is applied only inside the block at :75-76, after the NOON/NOOFF filter has already run. Traced both scenarios against the engine flag values (TRAPF_ONCE=1, INVERT=2, NOON=4, NOOFF=8, `Custom-API-reference.nut:401-406`): `Invert|NoOff` enters via the first disjunct and executes the Off action at :82-83 that NOOFF forbids; `Invert|NoOn` fires nothing although the inverted action (Off) is permitted. + +### Line 50 — `DStdButton.OnEndScript` overrides `DBaseTrap.OnEndScript` without calling `base.OnEndScript()`, so `::DHandler.DeRegisterAll(this)` never runs and per-frame registrations outlive the object. (new finding) + +- **Anchor:** `Physics.UnsubscribeMsg(self,ePhysScriptMsgType.kCollisionMsg)` +- **Severity:** P2 +- **Failure scenario:** A `DStdButton` with `DStdButtonDelay="3F"` is pressed, so `DCheckParameters` registers it with the handler (`SetData(_script+"InfRepeat", ::DHandler.PerFrame_Register(this, 3))`, Core.nut:2014). The object is later destroyed, or the script is removed/reloaded — `OnEndScript` fires, runs only `Physics.UnsubscribeMsg` (:51), and returns. `DBaseTrap.OnEndScript` (Core.nut:2044-2046), which exists precisely to call `::DHandler.DeRegisterAll(this)`, is shadowed and never reached, so the entry stays in `PerFrame_database` and `PerMidFrame` and the handler keeps invoking `FrameUpdate` on a destroyed instance every N frames for the rest of the mission. This is the "a specific handler suppresses the base handler" hazard from CLAUDE.md; the sibling `OnBeginScript` at :42-48 gets it right with `base.OnBeginScript()` at :47. +- **Verification:** Confirmed. `DBaseTrap.OnEndScript` (Core:2044-2046) consists solely of `::DHandler.DeRegisterAll(this)` and is the only teardown-side deregistration; `DStdButton.OnEndScript` (General:50-52) shadows it with just the physics unsubscribe and no `base.OnEndScript()`. The leaked registration is real: a `Delay="nF"` press reaches `::DHandler.PerFrame_Register` via Core:2012-2014 from ButtonPush's direct `DCheckParameters` call (General:78). + +### Line 78 — `ButtonPush()` goes straight from the frob/collision to `DCheckParameters`, skipping the `DCheckCondition` stage, so the universal `Condition`/`OnCondition`/`OffCondition` parameters have no effect on a button press. (new finding) + +- **Anchor:** `DCheckParameters(userparams(), kScriptTurnOn)` +- **Severity:** P2 +- **Failure scenario:** An author writes `DStdButtonCondition="$SomeQVar"` (or any of the documented condition forms) expecting the button to be inert until the QVar is set. `DBaseFunction` is the only place `DCheckCondition` is consulted for the On/Off actions (Core.nut:1805 and 1815), and it is never entered for a button push: `OnFrobWorldEnd` (:100) and `OnPhysCollision` (:88) are specific handlers, so `OnMessage`/`DBaseFunction` is suppressed for those messages, and `ButtonPush` calls `DCheckParameters(userparams(), kScriptTurnOn)` directly at :78. The condition is therefore silently ignored and the button always fires; `ExclusiveMessage` is likewise never evaluated on this path. (`Count`/`Capacitor`/`Delay`/`FailChance` *do* work, because they all live inside `DCheckParameters`, which makes the omission easy to miss.) +- **Verification:** Confirmed. Condition evaluation lives only in `DBaseFunction` (Core:1805 and 1815, ExclusiveMessage at Core:1825), which is reached through the generic `OnMessage` (Core:1643-1647); `OnFrobWorldEnd` (General:100) and `OnPhysCollision` (General:88) are specific handlers that suppress `OnMessage` for those messages (squirrel.osm ReadMe / CLAUDE.md), and `ButtonPush` calls `DCheckParameters(userparams(), kScriptTurnOn)` directly at General:78 — no path from a frob or collision ever consults `DCheckCondition`. + +### Line 89 — `OnPhysCollision` tests `message().collSubmod`, which is the submodel of the *colliding* object, where the comment's intent ("Collision with the button part") is this object's own submodel, `Submod`. (new finding) + +- **Anchor:** `if(message().collSubmod == 4)` +- **Severity:** P2 +- **Failure scenario:** An author shoots an arrow at a `DStdButton` lever expecting the documented arrow-activation. `sPhysMsg` (`DOC/squirrel_script/Custom-API-reference_messages.nut:234-239`) exposes `Submod` for the receiving object and then the `coll*` group — `collType`, `collObj`, `collSubmod` — describing the thing collided with (the same naming pattern as `contactObj`/`contactSubmod`). The arrow is `collObj` and its submodel is `collSubmod`, which for a single-submodel projectile is 0, never 4, so `ButtonPush()` is never reached and physical activation is dead; conversely, any collider that *does* happen to report submodel 4 activates the button no matter which part of the button it struck. The intended test is `message().Submod == 4`. +- **Verification:** Confirmed against `sPhysMsg` (`Custom-API-reference_messages.nut:234-248`): `Submod` is the receiving object's own submodel and the `coll*` group (`collType`, `collObj`, `collSubmod`, `collMomentum`, …) describes the collided-with object, matching the original Dark SDK layout where `collSubmod` is explicitly "the submodel of collObj". The script runs on the button (subscription on `self`, General:46), so General:89 tests the projectile's submodel where the button's own was meant. + +### Line 62 — `DarkGame.FoundObject(self)` is a Thief-only service, called unconditionally, so every `DStdButton` press throws in System Shock 2. (new finding) + +- **Anchor:** `DarkGame.FoundObject(self);` +- **Severity:** P2 +- **Failure scenario:** `DStdButton` is used in an SS2 mission (the framework supports SS2 throughout — `DCheckString`, `DPrint`, `DSendMessage` and the overlay layer all branch on `GetDarkGame() != 1`). `DarkGame` lives under the "THIEF SERVICES" heading in `DOC/squirrel_script/Custom-API-reference_services.nut` (heading at :624, block at :627-635), so on SS2 the root-table lookup of `DarkGame` fails and `ButtonPush()` throws at :62 — after the lock check, the sound and the tweq activation, but *before* the TrapControlFlags handling and `DoOn`/`DoOff`, so the joint animates and the sound plays but the button never relays anything. The call should be gated (`if (::GetDarkGame() != 1)`), and the author's adjacent TODO ("T1 comability?") only asks about Thief 1, where `FoundObject` does exist but requires API version 2+. +- **Verification:** Confirmed. `DarkGame` sits under the "THIEF SERVICES" divider (`Custom-API-reference_services.nut:623-635`; same in `API-reference_services.txt:432+`, whose preamble states that "an entire service" can be game-specific), so in SS2 the unresolved-name root lookup at General:62 fails at runtime. Order of effects verified in `ButtonPush`: lock check :56, sound :60, tweq activation :61 all precede the throw; TrapControlFlags handling (:64+) and `DoOn`/`DoOff` never run. The API-version-2+ note for T1 is at `Custom-API-reference_services.nut:633-635`. + +### Line 15 — `SafeDevice` removes `FrobInert` on *any* `TweqComplete` and has no failsafe if the device's tweq never completes, so it either unlocks too early or locks the object out permanently. (new finding) + +- **Anchor:** `Object.RemoveMetaProperty(self,"FrobInert")` +- **Severity:** P3 +- **Failure scenario:** (a) A lever that also carries a Models or Flicker tweq: `OnTweqComplete` fires for that unrelated tweq — `sTweqMsg` carries `Type`/`Op`/`Dir` fields (`Custom-API-reference_messages.nut:31-37`) which this handler never inspects — and `FrobInert` is dropped while the joint animation is still mid-swing, defeating the class's entire stated purpose ("prevent midway triggering of levers"). (b) A device whose joint tweq is halted or reversed by another script (`kTweqDoHalt`, or a `TWEQ_HALT_STOP` configuration) never emits `TweqComplete` at all, so `FrobInert` — added on the frob at :12 and persisted in the savegame as a metaproperty — is never removed and the object becomes permanently unfrobbable with no way to recover in game. (c) `RemoveMetaProperty(self,"FrobInert")` is unconditional, so it also strips a `FrobInert` that some other script or the archetype relied on. Filtering on `message().Type == eTweqType.kTweqTypeJoints` and only removing the meta if this script added it (or a bounded timer failsafe) would close all three. +- **Verification:** Confirmed from General:11-17: `OnTweqComplete` (:15-17) inspects no `message()` field although `sTweqMsg` carries `Type`/`Op`/`Dir` (`Custom-API-reference_messages.nut`), so any completing tweq of any type clears the lock; `FrobInert` is added on every `FrobWorldEnd` (:12) with no timer or other fallback, so a joints tweq that never completes leaves the object permanently frob-inert; and the removal at :16 is unconditional, stripping a `FrobInert` regardless of provenance. P3 fits: the common single-joint-tweq lever works as intended. + +## Cleanup items (7) + +- **Lines 134-148:** The pasted engine-API comment block documenting `ObjRaycast` is stale: it describes argument 6 as `BOOL bSkipMesh` ("if TRUE the raycast will not include mesh objects"), while the current API (`Custom-API-reference_services.nut:108-117`, API 11 / T2 v1.27) defines it as `int flags` — bit 0 skip-mesh, bit 1 rendered-only. The code at :183 actually depends on the *newer* bit-1 meaning, so the comment directly contradicts the line it documents and will mislead the next maintainer into "fixing" the flags. +- **Line 183:** `DGetParamRaw(_script + "RenderedOnly", 2, DN) + DGetParamRaw(_script + "IgnoreAI", 0, DN)` exposes raw bit values as user-facing Design Note numbers: an author must write `RenderedOnly=2` (not `1`) and `IgnoreAI=1`. Writing the intuitive `RenderedOnly=1` silently means "skip mesh objects", and `RenderedOnly=1;IgnoreAI=1` sums to 2, i.e. exactly the opposite of both requests. Additive composition of flag values is also fragile; `|` would at least be idempotent. +- **Lines 129-130 / 187:** `hobj`/`hloc` are class-member defaults holding engine reference objects, i.e. shared by every instance (CLAUDE.md's shared-mutable-default trap). The `// Need an integer, and DONT overwrite` comment at :187 shows the author was already wary of the aliasing; nulling them in a constructor would make the intent explicit and remove the stale-value class of bug entirely. +- **Line 130:** `hloc` is filled by the raycast on every scan and then never read anywhere in the class — the hit location is computed at full cost and discarded. +- **Line 174:** The parameter is spelled `ignore_set`, so the Design Note key is `DHitScanTrapignore_set` — snake_case and lower-cased, unlike every other parameter in the file (`From`, `To`, `HitMsg`, `RenderedOnly`, `AutoOff`, `Triggers`). Easy to get wrong and impossible to discover from the docstring, which never mentions it. +- **Lines 191-206:** The On and Off result blocks are a six-line verbatim duplication differing only in `TOnResult`/`TOffResult` and `"On"`/`"Off"`; `Triggers` is re-fetched from the Design Note in each branch (:192 and :200) although only one branch can run. A single loop or a small helper would halve it and guarantee the two paths stay in sync. +- **Lines 60 / 64 / 91:** Style inconsistencies that risk future bugs — :60 passes `null` for `PlayEnvSchema`'s `AgentObject` where the API declares `object AgentObject = 0` and every engine sample (`DOC/squirrel_script/samples/*.nut`) passes `0`, while the very next line correctly uses `OBJ_NULL`; the frobber that the samples pass here is simply dropped. :64 initialises with the engine constant `FALSE` and :65 with the Squirrel literal `true` two lines apart. :91 re-reads `RealFrobOnly` inside `OnPhysCollision` even though the subscription at :46 only exists when that parameter is false, and :51 unsubscribes unconditionally even when no subscription was ever made. + +## Incomplete items (6) + +- **Line 194 / Line 78:** Two of the author's own `// TODO: Test` markers sit on the exact lines that this review finds broken — the `TriggerMessages("On", DN)` action-argument handoff and `DStdButton`'s direct `DCheckParameters` call. Both are unverified by the author's own admission, and neither has been exercised (three separate P1 throws sit upstream of :194 on the default path). +- **Lines 181-191:** The `result` encoding that `TOnResult`/`TOffResult` are matched against is undocumented. `Engine.ObjRaycast` returns 0/1/2/3 (nothing/terrain/object/mesh), the code adds 1 and stringifies so that a substring search can be used, and the only hint is the inline aside "easier check for valid parameters via (3,4)". Neither the class docstring nor `docs/OPEN_TASKS.md` (T-81/T-82, which enumerate the operators and universal parameters to document) mentions `TOnResult`, `TOffResult`, `Triggers`, `AutoOff`, `RenderedOnly`, `IgnoreAI` or `ignore_set`. +- **Lines 118-120 vs 191:** The docstring's stated default behaviour — "By default when any object is hit a TurnOn will be sent to CD Linked objects" — is not implemented. Even if the empty-string crash is fixed, `"".find(result)` returns `null` for every result, so the out-of-the-box trap never triggers. A default of `"34"` (object or mesh hit) would match the documented promise. +- **Lines 219-226:** `DoOff`'s teardown targets `_script+"TInfRepeat"` / `_script+"TDelayTimer"`, and the key names *are* right (`DCheckParameters` writes them while `TriggerMessages` holds the `"T"` suffix, and `CreateHashKey` reads `instance._script`, which is why the append/slice dance at :223-225 is necessary). But per wave 1 the T-mode delay never resolves in the first place, so this cleanup guards a code path that cannot currently fire — it should be re-verified only after the wave-1 `DTrigger` timing fix lands. +- **T-namespace Count/Capacitor:** `DHitScanTrapTCount`/`TCapacitor` cannot work, because their data slots are only created by `ConstructParameters` (Core.nut:1606-1615) and `DTrigger.RepeatForCopies`'s second, `_TModus = true` pass returns immediately when no `…TCopies` parameter exists (the `if (DGetParam(GetClassName()+"Copies", …))` gate at Core.nut:1624 falls through to `return true` at :1639 without re-invoking the function). No `T`-prefixed counter or capacitor slot is ever `SetData`-ed, so the corresponding branches of `DCheckParameters` are unreachable for T-mode. Worth an explicit decision: either wire the T namespace into `ConstructParameters` or document that only `TDelay`/`TRepeat`/`TCondition` exist. +- **Top of file:** `DScript General.nut` has no file header — unlike `DScript Core.nut`, it carries no license banner, no version, and no note that it must sort *after* `DScript Core.nut` to be able to `extend DRelayTrap`/`DTrigger` (the load-order rule in CLAUDE.md). Line 1's fold banner is the only thing above `class SafeDevice`. + +## Suggestions (9) + +- **Give `TOnResult` a real default of `"34"` and `TOffResult` a default of `null` (not `""`), and add an early `if (str == "")` return to `DCheckString`.** — Fixes the P1 crash at both ends, makes the documented "any object hit ⇒ TurnOn" default actually happen, and stops every other `DGetParam(…, "")` call site in the repo from being one absent Design Note key away from the same throw. +- **Gate the hit message on the raycast result and make `hobj`/`hloc` per-instance.** — `result` is already computed one line earlier; sending `HitMsg` only for results 3 and 4 removes both the "messages an object nothing hit" bug and the cross-instance staleness, and per-instance storage costs one allocation per script rather than risking silent aliasing as soon as anything in the chain becomes asynchronous. +- **Read each ignore-set object's original `RenderType` (and whether it possessed one locally) before overwriting it, and restore that.** — Prevents the permanent Unlit→Normal / inherited→local rewrite, and makes the restore correct even if the set contains objects that were already `NotRendered`. +- **Replace the additive `RenderedOnly + IgnoreAI` flag arithmetic with boolean parameters mapped to bits: `(DGetParam(...+"RenderedOnly", true) ? 2 : 0) | (DGetParam(...+"IgnoreAI", false) ? 1 : 0)`.** — Makes the Design Note values intuitive (`=1`/`=0` like every other boolean parameter in the framework) instead of requiring authors to know the engine's bit layout, and removes the `1+1 == 2` collision. +- **Have `AutoOff` call only the infinite-repeat teardown rather than the whole `DCheckParameters`.** — Extracting Core's InfRepeat/per-frame stop block (Core.nut:1988-2007) into a small `DStopInfRepeat(DN, ScriptAction)` helper would let both `DHitScanTrap.DoOn` and `DHitScanTrap.DoOff` reuse it without spending Count/Capacitor charges or re-rolling `FailChance`, and would let `DoOff` drop its hand-written `_script` juggling. +- **Pass `kScriptTurnOn`/`kScriptTurnOff` to `TriggerMessages` (here and at the four SFX call sites), or normalise the string to the integer inside `TriggerMessages` before it reaches `DCheckParameters`.** — One change fixes the un-stoppable per-frame T repeat, the dead `TCountOnly` arithmetic and the `"On"`-in-the-timer-payload corruption for every `DTrigger` subclass at once; the string form is only needed downstream, for `DRelayMessages`' parameter names. +- **Have `DStdButton.ButtonPush` set `SourceObj` (`message().Frobber` for the frob path, `message().collObj` for the collision path) and run the same `DCheckCondition` gate `DBaseFunction` uses.** — Today `[source]`-based targets and `DStdButtonToQVar` record `null` (`SourceObj`'s initialiser, Core.nut:1590) because nothing on the button path ever assigns it, and `Condition` is silently ignored. Factoring `DBaseFunction`'s condition-then-parameters pair into one reusable method would keep any future direct-activation script from repeating the omission. +- **Guard the Thief-only call as `if (::GetDarkGame() != 1) DarkGame.FoundObject(self)`, and guard `Property.Get(self,"Locked")` with `Property.Possessed` the way `TrapFlags` is guarded eight lines below.** — Restores SS2 usability and makes the Locked check consistent with the file's own defensive style for optional properties. +- **Expose the hit location: store `hloc` into an optional `DHitScanTrapToQVar`-style target or pass it as message data to the `HitMsg` recipient.** — The value is already paid for on every scan; surfacing it turns the class into something usable for placement/teleport/effect work instead of a pure boolean line-of-sight test, and it is the natural companion to the `Triggers` filter. diff --git a/docs/review/wave2/general-utility-traps.md b/docs/review/wave2/general-utility-traps.md new file mode 100644 index 0000000..c0713cf --- /dev/null +++ b/docs/review/wave2/general-utility-traps.md @@ -0,0 +1,82 @@ +# DWatchMe + DCopyPropertyTrap + DCompileTrap + DAddScript + DStackToQVar + +**File:** `DScript General.nut` · **Anchor:** line 233 + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +This unit is five small, independent `DBaseTrap` utilities with no message-relay plumbing between +them: `DWatchMe` (233-273) wires `AIWatchObj` links on `BeginScript`, `DCopyPropertyTrap` (276-300) +bulk-copies properties between object sets, `DCompileTrap` (304-311) runs a Design Note string +through the `_` expression compiler, `DAddScript` (314-372) pokes the `Script 3` property slot on +other objects, and `DStackToQVar` (377-407) mirrors a stackable item's `StackCount` into a QVar. +None of the five relay messages onward or hold complex timing state, so most of the framework-level +hazards (Copies/`_script` juggling, timer payload ordering, `::callee()` misuse) that dominate the +rest of the file don't apply here — the bugs found in this unit are smaller and mostly of the same +shape: a required Design Note parameter that silently defaults to `null`/empty, then gets handed +unguarded into a native call or a string concatenation that cannot digest it. Two are the tracked +`T-47` (slot-check compares against the wrong thing) and `T-48` (hardcoded parameter name) rows +named in scope, both reconfirmed at their current lines. On the "Wave 1 SetQVar" question: `DStackToQVar` +does **not** route through `DScript.SetQVar`/`GetQVar` at all — its `StackToQVar()` calls the native +`::Quest.Set(qvar, value, eQuestDataType.kQuestDataMission)` directly — so it is unaffected by Wave 1's +`_GetQVarType`/`SetQVar` findings; it has its own, narrower and unrelated null-handling bug instead +(see below). `DWatchMe`'s only real gap is an author-acknowledged TODO about property-priority order, +listed under Incomplete rather than as a bug since the code does exactly what its own doc comment +says. Overall the unit is functionally usable for its "happy path" (all Design Note parameters +correctly set) but has no defensive handling at all for a missing/misspelled required parameter, +unlike sibling classes elsewhere in the framework (`DTrapSetQVar`, `DTrigQVar`) which explicitly +guard the same shape of input before doing anything dangerous with it. + +## Confirmed bugs (4) + +### Line 295 - `DCopyPropertyTrap.DoOn` passes a bare `null` as the property name into the native `Property.CopyFrom` call when the required `Property` parameter is not set in the Design Note. (new finding) + +- **Anchor:** `::Property.CopyFrom(to, prop, source)` +- **Severity:** P2 +- **Failure scenario:** `local props = DGetParam(_script + "Property", null, DN, kReturnArray)` (line 291) has `defaultValue = null` and `returnInArray = true`. When `DCopyPropertyTrapProperty` is absent from the Design Note, `DGetParam` falls through to `DCheckString(null, true)`, which (per `_FormatForReturn`, `Core:213`) does **not** collapse to an empty array — a non-array parameter with `inArray` requested is wrapped as `[param]`, so `props` becomes `[null]`, not `[]`. The `foreach (prop in props)` loop at 294-296 then still runs once, calling `::Property.CopyFrom(to, null, source)` against the native signature `HRESULT CopyFrom(object targ, string prop, object src)` (`Custom-API-reference_services.nut:205`), which expects a `string` in the middle slot. Unlike the sibling QVar classes (`DTrapSetQVar.PrepareSetQVar`, `DTrigQVar.CheckQuest`), which both explicitly check their required raw parameter for falsiness and `DPrint` a clear error before doing anything else, `DCopyPropertyTrap` has no such guard, so an object with the script but a forgotten/misspelled `DCopyPropertyTrapProperty` key does not get a diagnostic — it hits the native call with a `null` where a `string` is required. +- **Verification:** Confirmed. `DGetParam` with an absent key routes the `null` default into `DCheckString(null, kReturnArray)` (`Core:1504-1506`); the `case "null"` branch (`Core:1014-1017`, whose `DPrint` warning at `Core:1016` is commented out, so there is no diagnostic) returns `_FormatForReturn(null, true)`, and `_FormatForReturn` (`Core:213-223`) wraps any non-array parameter as `[param]` — so `props` is `[null]`, never `[]`. The `foreach` at `General:294-296` therefore runs once per target and calls `::Property.CopyFrom(to, null, source)`, whose declared signature is `HRESULT CopyFrom(object targ, string prop, object src)` (`Custom-API-reference_services.nut:205`); per the data-type table (`Custom-API-reference.nut:20-40`) only `cMultiParm` admits `null`, not `string`. The contrasted guards do exist: `DTrapSetQVar.PrepareSetQVar` (`Core:2741-2743`) and `DTrigQVar.CheckQuest` (`Core:2892-2893`). + +### Line 331 - `DAddScript.AddScriptToObj`'s slot-availability check accepts the target's `Script 3` slot whenever the *archetype* has any non-empty `Script 3` value at all, instead of checking that value matches `newscript`. (tracked: T-47) + +- **Anchor:** `::Property.Get(::Object.Archetype(obj),"Scripts","Script 3") || i == S_OK` +- **Severity:** P2 +- **Failure scenario:** `local i = ::Property.Get(obj, "Scripts","Script 3")` is the *target object's own* current slot 4 script. The guard `if (i == "" || ::Property.Get(::Object.Archetype(obj),"Scripts","Script 3") || i == S_OK)` treats a truthy archetype-level `Script 3` (any non-empty string, regardless of its content) as sufficient license to overwrite `obj`'s own slot — even when `i` is itself a different, unrelated, already-in-use script that has nothing to do with the archetype's value or with `newscript`. Concretely: archetype has `Script 3 = "SomeUnrelatedScript"`, and `obj` (an instance) was separately given its own `Script 3 = "SomeManuallyPlacedScript"` directly by the level designer; `DAddScript` targeting `obj` with `newscript = "DAddedScript"` will silently overwrite `"SomeManuallyPlacedScript"` with `"DAddedScript"`, because the middle `||` clause is true, never reaching the "in use, don't touch" `DPrint` warning branch that the class doc explicitly promises ("you should be aware of if there is any collision"). +- **Verification:** Confirmed (tracked T-47, tag correct). `local i` at `General:329` is the target object's own slot value; in the guard at `General:331` the middle disjunct `::Property.Get(::Object.Archetype(obj),"Scripts","Script 3")` is truthy for *any* archetype-level Script 3 string (in Squirrel every string, even `""`, is true — only the property-missing `0`/`S_OK` return is falsy), so with the scenario's values the `||` short-circuits past both `i` comparisons and `::Property.Set` at `General:332` overwrites the instance's unrelated script; the `DPrint` warning at `General:334` is unreachable in that case. + +### Line 395 - `DStackToQVar.GetObjOnPlayer` can fall off the end returning `null` when no inventory object matches the given archetype, and that `null` is then passed as the object argument to two `Property.Get` calls, contradicting the inline comment's assumption that this degrades gracefully. (new finding) + +- **Anchor:** `invObj = GetObjOnPlayer(Object.Archetype(self))` +- **Severity:** P2 +- **Failure scenario:** `GetObjOnPlayer(type)` (383-390) only ever returns inside its `foreach` loop when it finds a `Contains`-linked player inventory object whose archetype matches `type`; if none matches, the function has no trailing `return` and implicitly yields `null`. `StackToQVar` only takes this path when `message().message == "Create"` (the message fired for a freshly split-off world copy of a stacked item), and its own comment on this line ("If non exist Property.Get will return 0") assumes `Property.Get(invObj, "StackCount")` tolerates a missing object gracefully — but `invObj` here is Squirrel's `null`, not `OBJ_NULL`/`0`, and `Property.Get`'s native signature (`Custom-API-reference_services.nut:195`) takes `object obj`, a typed integer parameter; if that occurs when the item being dropped was the sole remaining copy in the player's inventory (no sibling of the same archetype left to match), the two subsequent `Property.Get(invObj, "StackCount")` calls (lines 398 and 400) run against `null` rather than a valid/zero object id, which is a different failure mode than the "returns 0" the comment assumes and can abort `StackToQVar` (and, if a `qvar` was supplied, skip the `Quest.Set` write) instead of recording an empty/zero stack count. +- **Verification:** Confirmed. `GetObjOnPlayer` (`General:383-390`) returns only from inside its `foreach` over `Contains` links; with no matching inventory object it falls off the end and yields Squirrel `null` — not `0`/`OBJ_NULL`. That `null` reaches `Property.Get(invObj,"StackCount")` at `General:398/:400`, whose first parameter is a typed `object` (`cMultiParm Get(object obj, string prop, string field = null)`, `Custom-API-reference_services.nut:195`); per the data-type table (`Custom-API-reference.nut:23,30,34`) an `object` argument must be an integer ID or a name string — only `cMultiParm` admits `null`. Reachable: `DefOn` (`General:381`) includes `"Create"`, so any runtime-created instance of the archetype while the player carries none of them takes this branch (`General:394-395`), and the inline comment's "Property.Get will return 0" assumption presumes an empty ObjID, which is not what the function actually returns. + +### Line 404 - `DStackToQVar.DoOn` hardcodes the literal parameter name `"DStackToQVarVar"` instead of building it from `_script`, so the QVar-name override breaks under `Copies` and in the `DModelByCount` subclass. (tracked: T-48) + +- **Anchor:** `StackToQVar(DGetParam("DStackToQVarVar", Property.Get(self,"TrapQVar"),DN))` +- **Severity:** P2 +- **Failure scenario:** Every other parameter read in this class (and the framework convention documented in CLAUDE.md) builds the Design Note key as `_script + "Foo"` so it tracks the effective class name — including the `Class2`..`Class9` names `RepeatForCopies` assigns for `Copies`, and the class name `DModelByCount` uses when it `extends DStackToQVar` (confirmed present and active in `DScript SFX.nut:1394`). Here the literal string `"DStackToQVarVar"` is used instead, so on a `DModelByCount`-scripted object, the Design Note key a mission author would naturally write (`DModelByCountVar=...`) is never read — only a stray, undocumented `DStackToQVarVar` key would work, and under `Copies` only the first (unsuffixed) copy's key is ever read since `_script` for copies 2-9 never matches the hardcoded literal at all. This is unrelated to the Wave 1 `DScript.SetQVar` findings noted above, since this class writes via the native `Quest.Set` directly and never calls `DScript.SetQVar`/`GetQVar`. +- **Verification:** Confirmed (tracked T-48, tag correct). The literal `"DStackToQVarVar"` at `General:404` violates the `_script + "Foo"` convention (CLAUDE.md), so under `Copies` the mutated `_script` (`DStackToQVar2`…`9`) never selects a copy-specific key — every copy reads the same one. One nuance vs. the scenario as written: the `DModelByCount` leg is weaker than stated, because `DModelByCount.DoOn` (`SFX:1407-1409`) *overrides* `DoOn` and calls `::StackToQVar()` directly with no qvar argument (cf. T-18), so `General:404` is never executed for that subclass — a `DModelByCountVar` key is indeed silently ignored, but because nothing reads any Var key on that path, not because of the hardcoded literal. The Copies breakage and the convention violation stand as tracked. + +## Cleanup items (3) + +- **Line 336** (`// print("Done" + obj)`): commented-out leftover debug print inside `AddScriptToObj`, dead weight next to the `#DEBUG ERROR` tag above it. +- **Line 357** (`function DRemoveSciptFunc(DN){`): typo'd function name ("Sciptfunc") inconsistent with its sibling `DAddScriptFunc` two lines above — hurts future `grep -a "ScriptFunc"` sweeps and readability. +- **Line 393** (`local invObj = self // Create and combine is directly the script object.`): the comment is misleading against the actual code — the special-case lookup two lines below (`GetObjOnPlayer`) fires specifically on the `"Create"` message, while this default `invObj = self` line is what actually covers `"Contained"` and `"Combine"`. As written the comment reads as if `"Create"` were one of the messages using `self` directly, the opposite of what the `if` below does; a future maintainer trusting the comment over the code risks "fixing" the condition and breaking the (plausibly intentional) current behavior. + +## Incomplete items (3) + +- **Line 241** (`TODO: If the object has a custom one it should take priority.`): `DWatchMe.DoOn` (255-259) unconditionally copies the archetype's `AI_WtchPnt` property onto `self` whenever the archetype has one, with no check for a custom per-object `AI_WtchPnt` a level designer may have set directly on the instance; the author's own TODO flags that an object-level override should win but that priority ordering is not implemented — current behavior matches the class's own doc comment (236-240), so this is a documented gap rather than a hidden bug. +- **Line 261** (`// Else the Watch links default property of the script object will be used automatically on link creation (hard coded). The Archetype has priority. TODO: Change this the other way round.`): same underlying gap as above, restated by the author at the point where the fallback-to-self path is described — the intended "object beats archetype" priority is explicitly called out as not yet implemented. +- **Line 320** (`TODO: Make this optional, dump warning`): `DAddScript`'s class doc acknowledges that `AddScriptToObj` unconditionally attempts to write `Script 3` and that DromEd will error if it can't be overridden, and marks making that behavior optional (with a warning instead of a hard failure) as a still-open TODO. + +## Suggestions (4) + +- **Guard `DCopyPropertyTrap.DoOn`'s `props` the same way `DTrapSetQVar`/`DTrigQVar` guard their required raw parameters** — `if (!props[0]) return DPrint("ERROR: No Property parameter set for " + _script, kDoPrint)` before the `foreach` loop would turn the null-into-native-call crash into the same clear diagnostic the QVar classes already give for the equivalent mistake. +- **Add the same style of guard to `DCompileTrap.DoOn`** — `local code = DGetParamRaw(_script + "Code"); if (!code) return DPrint("ERROR: No Code parameter set for " + _script, kDoPrint)` before the `"_" + code` concatenation, matching the established pattern instead of leaving it as the one unguarded `_`-operator call site in the file. +- **Have `DStackToQVar.GetObjOnPlayer` return a documented, safe fallback (e.g. `self`, or `OBJ_NULL`) instead of implicit `null`** when no matching inventory object is found, so the two downstream `Property.Get(invObj, ...)` calls always receive a well-typed object argument and the "returns 0 if non existent" comment is actually true. +- **Rename `DRemoveSciptFunc` to `DRemoveScriptFunc`** (kept as the cleanup item above) — trivial, but worth doing in the same pass as any other edit to `DAddScript` since it's a one-word fix with no behavioral risk. + +## Candidate findings rejected on verification (1) + +- **Line 309:** `DCompileTrap.DoOn` concatenates `"_"` with the possibly-`null` result of `DGetParamRaw`, with no guard, unlike every other `_`-operator call site in the framework - _refuted:_ the claimed mechanism misreads Squirrel semantics. In Squirrel 3 (the language `squirrel.osm` embeds), `+` with a string operand does not throw on a non-numeric RHS: the VM routes any `+` whose operand type mask includes a string through string concatenation, which stringifies *any* other operand — null included — so `"_" + null` succeeds (yielding `"_null"` or `"_(null : 0x…)"` depending on the VM's null stringification) rather than raising the claimed "STRING+NULL not supported" runtime error at `General:309`. What happens downstream — `DCheckString`'s `_` case (`Core:1352`) → `CheckAndCompileExpression` (`Core:673-684`) → `compilestring` — is then either a harmless `return (null)` no-op or a compile error, neither of which is the claimed throw-at-concatenation, and which of the two occurs cannot be positively confirmed by static analysis. The missing-diagnostic improvement remains recorded under Suggestions. diff --git a/docs/review/wave2/overlays.md b/docs/review/wave2/overlays.md new file mode 100644 index 0000000..1a9ec70 --- /dev/null +++ b/docs/review/wave2/overlays.md @@ -0,0 +1,68 @@ +# cDIngameLogOverlay + cDHandlerFrameUpdater + cDWorldInvOverlay + +**File:** `DScript Overlays.nut` · **Anchor:** line 1 + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +`DScript Overlays.nut` is the thin, per-game (`IDarkOverlayHandler` vs `IShockOverlayHandler`) +overlay layer: the top-level `Overlayclass`/`::gGameOverlay` selection (1-9) is correct and matches +`docs/squirrel_script/ReadMe.txt`'s documented contract exactly (derive from the right interface, +declare only the handler methods you use, install via `AddHandler`/`RemoveHandler` — all of which is +actually done from `DScript Core.nut`, not this file). `cDIngameLogOverlay` (13-95) and +`cDHandlerFrameUpdater` (97-136) both call the coordinate-mapping and drawing service functions only +from the handler contexts the API reference requires (`WorldToScreen`/`GetObjectScreenBounds` only +from `DrawHUD`, `Begin/EndTOverlayUpdate`+`FillTOverlay`+`DrawTOverlayItem` only from `DrawTOverlay`), +so the handler lifecycle itself is sound. Two already-tracked defects reproduce exactly as described +in `docs/OPEN_TASKS.md` at their current line numbers: `T-52`'s `SizeX`-for-`SizeY` typo (now at +lines 41 and 70, both still present, both still duplicated between the constructor and +`OnUIEnterMode`) and `T-78`'s `#HELP ME` Shock 2 log-filename guess (line 28). The file's coupling to +`::DHandler.PerMidFrame_DoUpdates()` (line 126, inside `cDHandlerFrameUpdater.DrawHUD`) is the exact +call site that reaches `DScript Core.nut:2410`'s hard reference to `DHudObject.pos_vector` — i.e. the +tracked `T-39` — confirming `cDHandlerFrameUpdater` is a live, mandatory trigger for that bug +whenever any `PerMidFrame` consumer is active, not just incidental context. Beyond the tracked items, +one new, concrete defect was found in `cDWorldInvOverlay` (141-179): its per-frame stack-count label +passes a raw integer property value straight into `DrawString`, whose native signature requires an +actual `string`, with no `.tostring()`/concatenation anywhere in the call — exactly the conversion +this same codebase's own sample (`T2OverlaySample.nut:249`) and the API reference both show is +required. Since `kDInvMasterExtraInfo` (which gates this whole class) defaults to `3` in +`DSConfigDefault.nut`, this is a default-on code path, not an edge case. + +## Confirmed bugs (2) + +### Line 158 - `cDWorldInvOverlay.DrawHUD` passes the raw integer `StackCount` property value straight to `DrawString`, whose native signature requires a `string`, with no `.tostring()`/concatenation anywhere in the call. (new finding) + +- **Anchor:** `::gGameOverlay.DrawString(Property.Get(item,"StackCount"), X2.tointeger() - 15, Y2.tointeger() - 15);` +- **Severity:** P1 +- **Failure scenario:** `kDInvMasterExtraInfo` defaults to `3` in `DSConfigDefault.nut:35` ("will display stack ... Name of every item"), and `DInventoryMaster.DoOn`/`Update()` (`DScript SFX.nut:734-736, 763-764`) register a live `cDWorldInvOverlay` and copy each in-world item's real `StackCount` property onto its display dummy whenever that constant is truthy — i.e. this is the shipped default, not an opt-in debug path. `StackCount` is confirmed numeric elsewhere in the same repo (`DScript SFX.nut:1400`: `GetProperty("StackCount") - 1`; `DOC/squirrel_script/samples/SS2_samples.nut:28`: `if ( !GetProperty("StackCount") )`, commented as "0 or ... doesn't have the property"). `Custom-API-reference_services.nut:823` types the call as `DrawString(string text, int x, int y)`, and the only other call site of `DrawString` that draws a numeric value anywhere in the repo (`T2OverlaySample.nut:249`) first does `local s = "miss" + DarkGame.GetCurrentMission()` to force a string before passing it in — the codebase's own precedent for exactly this conversion. Here, `Property.Get(item,"StackCount")` is passed unconverted. The guard on line 157 (`if (Property.Get(item,"StackCount"))`) only filters out `null`/`0`, so the very first time a player picks up a second arrow (or any other stackable item with `StackCount` > 1) and that item's in-world display dummy is on-screen, `DrawHUD` calls `DrawString` with an integer argument where the native binding expects a string, throwing instead of drawing the stack count label. +- **Verification:** Confirmed by independent trace of the full path: the `if (kDInvMasterExtraInfo)` gate at `DScript Overlays.nut:138` is truthy by default (`DSConfigDefault.nut:35`, `= 3`), `DInventoryMaster.DoOn` registers the overlay (`DScript SFX.nut:763-764`) and `Update()` copies the real item's `StackCount` onto the display dummy and appends it to `items` (`DScript SFX.nut:735-736`). The guard at `Overlays.nut:157` only tests truthiness, and line 158 passes the raw `Property.Get` result — an integer per the arithmetic on the same property at `SFX.nut:1400` — where `Custom-API-reference_services.nut:823/1038` requires `string text`; `DOC/squirrel_script/ReadMe.txt:200-203` explicitly says wrong argument types are run-time errors that fire when the code path executes. No `.tostring()`/concatenation exists anywhere between `Property.Get` and `DrawString`, and no upstream caller filters stacked items out. Genuinely new — no OPEN_TASKS.md row covers it. + +### Line 41 - `cDIngameLogOverlay`'s negative-`Y` custom-position branch reads `SizeX` where `SizeY` is meant, corrupting the log's vertical position whenever a custom position string with negative `Y` is configured. (tracked: T-52) + +- **Anchor:** `Y = SizeX.tointeger() + Y` +- **Severity:** P3 +- **Failure scenario:** With `kUseIngameLog` set to a custom `"X/Y"` string whose `Y` component is negative (e.g. `"20/-30"`, meaning "30 px up from the bottom" per the class's own right/bottom-relative convention), the constructor calls `::Engine.GetCanvasSize(SizeX, SizeY)` and then computes `Y = SizeX.tointeger() + Y` instead of `SizeY.tointeger() + Y` — using the canvas *width* to offset a *vertical* coordinate. Unless the display happens to be square, the resulting `Y` is not the intended distance from the bottom edge, so the in-game log is drawn at the wrong vertical position (and the identical mistake repeats verbatim in `OnUIEnterMode`, line 70, so re-entering UI mode doesn't self-correct it). The default config (`kUseIngameLog = true`, i.e. the hardcoded `"-480/0"`-equivalent path) never reaches this branch since its `Y` is always `0`, so the bug is dormant unless a mission/mod author opts into the commented-out custom-position example in `DSConfigDefault.nut:53` with a negative `Y`. +- **Verification:** Confirmed at the current line numbers: `DScript Overlays.nut:41` reads `Y = SizeX.tointeger() + Y` inside the `if (Y < 0)` branch directly after `::Engine.GetCanvasSize(SizeX, SizeY)` at `:37`, and the identical line repeats at `:70` in `OnUIEnterMode`; the sibling `if (X < 0)` branch (`:38-39` / `:67-68`) shows `SizeY` was meant. The path is only reachable when `kUseIngameLog` is a custom string with negative `Y` (default is `true` at `DSConfigDefault.nut:51`), matching the dormancy claim. This restates the tracked T-52 row, and the entry's `tracked: T-52` tag is correct as written. + +## Cleanup items (4) + +- **Line 32-46 / 60-75** (`if (typeof kUseIngameLog == "string"){`): The custom-position parsing block (split the string, assign `X`/`Y`, clamp negatives against canvas size) is duplicated verbatim between the constructor and `OnUIEnterMode`, including the `T-52` typo in both copies — the two block should be factored into one shared method so a fix only has to be applied once. +- **Line 87** (`//::gGameOverlay.UpdateTOverlaySize(blackbg, SizeX.tointeger(), SizeY.tointeger())`): Commented-out resize call left in `DrawTOverlay`; see Incomplete items below — this is the mechanism that would keep the black background box matched to the log text's rendered size. +- **Line 108** (`// Engine.GetCanvasSize(W,H)`): Leftover commented-out call in `cDHandlerFrameUpdater`'s constructor referencing local variables (`W`,`H`) that aren't declared anywhere in the class — dead scaffolding from an earlier version of the canvas-size lookup. +- **Line 128 / 164 / 168-169** (`// ::gGameOverlay.GetObjectScreenBounds(430, X1, Y1, X2, Y2);`, `// ::gGameOverlay.SetTextColor(255,127,63)`, `//::gGameOverlay.GetStringSize(extra,X2,Y1)` / `//::gGameOverlay.DrawLine(...)`): Several more commented-out experimental calls (an alternate text color, an underline effect for `DesignNote` display) left in place across `cDHandlerFrameUpdater`/`cDWorldInvOverlay` — harmless but adds noise; either finish or remove. + +## Incomplete items (2) + +- **Line 87** (`//::gGameOverlay.UpdateTOverlaySize(blackbg, SizeX.tointeger(), SizeY.tointeger())`): The black background box (`blackbg`) is created once in the constructor at a fixed `631x640` size and never resized afterward — this commented-out call is the only code in the file that would keep it matched to the actual rendered log text dimensions (`SizeX`/`SizeY`, updated every frame by `DrawHUD`'s `GetStringSize` call at line 57). As shipped, the background box's size is fixed at creation and does not track the log content, matching the concern already raised in `T-52`'s notes. +- **Line 28** (`case 1: Logfile = ::dfile("Shock2.log"); break // TODO #HELP ME correct name`): Author's own open question about the correct SS2 game-log filename, part of the broader tracked `T-78` Shock 2 support gap — left unresolved. + +## Suggestions (3) + +- **Give `cDWorldInvOverlay.DrawHUD`'s stack-count label an explicit conversion (`Property.Get(item,"StackCount").tostring()` or `"" + Property.Get(item,"StackCount")`) before passing it to `DrawString`, matching the pattern already used at `T2OverlaySample.nut:249` for the same kind of numeric-to-label conversion.** This is the direct, minimal fix for the bug above and keeps the convention consistent with the rest of the file's other `DrawString` call sites, which are already passing genuine strings (`DLogString`, `extra`). +- **Add a length/format guard around the `kUseIngameLog` custom-position string parsing (`::split(kUseIngameLog, "/")`), consistent with CLAUDE.md's "split() drops empty tokens" gotcha.** As written, a malformed custom value missing one half of the `"X/Y"` pair (e.g. a leading `"/Y"` with no `X`) collapses to a single-element array and `s[1]` throws, rather than falling back to the documented default; the constant is author-edited rather than mission-author-facing, so the risk is low, but a guard would fail more gracefully. +- **Give `cDHandlerFrameUpdater.ScreenToWorld`'s linear `0.01`-step search (lines 111-123) a coarser initial step with refinement, or cache the result across resolution-stable frames, instead of a fixed fine-grained linear scan.** It only re-runs after `OnUIEnterMode` sets `NotChecked = true`, so the cost is bounded to one frame per UI-mode entry rather than every frame, but each call can still iterate several thousand `WorldToScreen`/`CameraToWorld` round-trips synchronously inside a single `DrawHUD`. + +## Candidate findings rejected on verification (1) + +- **Line 28:** `cDIngameLogOverlay`'s constructor guesses the SS2 game log filename with an explicit `#HELP ME` marker instead of a confirmed name. (tracked: T-78) - _refuted:_ The claimed failure mechanism is wrong: `dfile`'s constructor (`DScript File&Blob.nut:27-35`) does not throw on a missing file — its `catch(notfound)` branch calls `error(...)` and then `return`s, leaving `myblob = null`, so a wrong filename would surface as later per-frame null errors in `DrawHUD`, not as a constructor throw disabling the overlay at creation. More importantly, the premise that the name may be wrong is unconfirmed and most likely false: `"Shock2.log"` is exactly the SS2 game-log filename the project's own CLAUDE.md documents ("Errors surface in ... `Thief2.log` / `Shock2.log` (game)"), so no concrete failure path exists in the current tree. The author's unresolved `#HELP ME` marker itself is real but is an open TODO already tracked as T-78 and already listed under this file's Incomplete items, not a demonstrable bug. diff --git a/docs/review/wave2/qvar-traps.md b/docs/review/wave2/qvar-traps.md new file mode 100644 index 0000000..1436273 --- /dev/null +++ b/docs/review/wave2/qvar-traps.md @@ -0,0 +1,168 @@ +# DTrapSetQVar + DScript.Quest + DTrigQVar + DTrapDeleteQVar + +**File:** `DScript Core.nut` · **Anchor:** line 2732 + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +This unit is the user-facing half of the QVar system: `DTrapSetQVar` (2732-2816) writes QVars from a +Design Note expression or from the `TrapQVar` property, the `DScript.Quest` table (2818-2875) is the +in-Squirrel publish/subscribe registry, `DTrigQVar` (2877-2960) turns a QVar change into a +DoOn/DoOff, and `DTrapDeleteQVar` (2962-2972) removes a QVar. The straight-line `DTrapSetQVar` path +(`[Script]OnOperation="VAL+1"` on a `TurnOn`) is the only one that traces cleanly end to end. +`DTrigQVar` cannot work at all as written: line 2931 passes `kReturnArray` into `DGetParam`'s **`DN`** +slot, so the `[Script]Name` Design Note parameter is never read and the result is never wrapped in an +array, and the fallback it then falls back to reads a property named `"QuestVar"` where every other +site in the repo (and the LG sample) uses `"TrapQVar"` — so `vars` is `null`, `OnBeginScript` takes +the error branch, and nothing is ever subscribed. Even after that, the `NAME != DGetParam(_script + +"Name")` gate at 2888 rejects the `"*"` wildcard, every name but the last of a `+`-list, and any +mixed-case name (because `DScript.SetQVar` lowercases before notifying), and the tracked `::callee` +misuse at 2887 throws "wrong number of parameters" (not the unbounded recursion T-30 describes) the +moment `Copies` is used. `DTrapSetQVar`'s property-init path (`InitQVarFromProp`) is the roughest +code in the unit — it runs on sim *shutdown* as well as sim start, two `==`/`=` typos make its +documented `""` form a no-op, an unguarded `event[i+1]` throws on an odd token count, and an init +value of `0` is rejected by the guard at 2741. Wave 1's `DScript.SetQVar` / `DeleteQVar` / +`_GetQVarType` findings sit directly underneath all of this and are cross-referenced rather than +repeated; note in particular that `DTrapDeleteQVar` only ever calls `DeleteQVar` with `type = null`, +the exact argument shape wave 1 flagged. + +## Confirmed bugs (15) + +### Line 2931 - `DGetParam(_script + "Name", …, kReturnArray)` passes `kReturnArray` in the `DN` parameter slot, so the `[Script]Name` Design Note parameter is never read and the result is never returned as an array. (new finding) + +- **Anchor:** `DGetParam(_script + "Name", ::Property.Get(self, "QuestVar"),kReturnArray)` +- **Severity:** P1 +- **Failure scenario:** `DGetParam`'s signature is `DGetParam(par, defaultValue = null, DN = null, returnInArray = false)` (`Core:1499`). The call at 2931 supplies three positional arguments, so `DN = kReturnArray = true` and `returnInArray` keeps its default `false`. `if(!DN){DN = userparams()}` does not fire because `true` is truthy, and `if (par in DN)` becomes `"DTrigQVarName" in true`, which evaluates to `false` (verified: Squirrel's `_OP_EXISTS` does not raise on a non-container, it just yields `false`). So an author who writes `DTrigQVarName="loot_count"` in the Design Note has that value silently ignored, and the property fallback is used instead. Second, because `returnInArray` is `false`, `vars` is a single value rather than the array `foreach (var_name in vars)` on 2933 expects: if the fallback yields a string such as `"loot_count"`, `foreach` iterates the string and hands `SubscribeMsg`/`Quest.SubscribeMsg` the **integer character codes** `108, 111, 111, …` instead of the name (verified: `foreach (c in "abc")` yields `97, 98, 99` as integers), and `::Quest.SubscribeMsg(self, 108, 0)` fails its `string` typemask. The fix is `DGetParam(_script + "Name", ::Property.Get(self, "TrapQVar"), null, kReturnArray)`. +- **Verification:** CONFIRMED. `DGetParam`'s signature is exactly as claimed at `Core:1499` (`par, defaultValue = null, DN = null, returnInArray = false`), and the call at `Core:2931` passes three positional arguments, so `kReturnArray` (defined `const kReturnArray = true`, `Core:92`) lands in `DN` while `returnInArray` stays `false`. `if(!DN)` at `Core:1503` does not fire for `true`, `"DTrigQVarName" in true` is `false` (Squirrel 3's `_OP_EXISTS` uses a non-raising raw Get and yields false on non-containers), so `Core:1506` returns `DCheckString(defaultValue, false)` — the Design Note parameter is never consulted and the result is never wrapped in an array. The secondary foreach-over-string consequence is hypothetical (needs the fallback to yield a string), but the primary mechanism is fully traced. + +### Line 2931 - The `Name` fallback reads a property called `"QuestVar"`, but the property used everywhere else in the repo (and by the LG sample) is `"TrapQVar"`, so the fallback yields nothing and `DTrigQVar` never subscribes to any QVar. (new finding) + +- **Anchor:** `::Property.Get(self, "QuestVar")` +- **Severity:** P1 +- **Failure scenario:** `grep -arn "QuestVar\|TrapQVar"` over the tree shows `"TrapQVar"` at `Core:2775`, `Core:2776`, `Core:2802`, `Core:2891`, `General.nut:404` and in `DOC/squirrel_script/samples/T2_samples.nut:269-271`; `"QuestVar"` appears exactly once, here. Given the previous finding makes the Design Note parameter unreachable, this fallback is the *only* source of the QVar name, so for an object set up the documented way (a `TrapQVar` property naming the QVar, as `DTrapSetQVar` and `DStackToQVar` both read it), `::Property.Get(self, "QuestVar")` returns nothing, `vars` is null, `if (vars)` on 2932 is false, control goes to the `DPrint("ERROR: … no valid QVar name…")` branch on 2943, and neither `::DScript.Quest.SubscribeMsg` nor `::Quest.SubscribeMsg` is ever called. `DTrigQVar` then receives no `QuestChange` at all and the entire script is dead — consistent with the file header's own "only minimally tested" warning. +- **Verification:** CONFIRMED. Repo grep reproduces the claim exactly: `"QuestVar"` occurs once in the entire tree (`Core:2931`) while `"TrapQVar"` is used at `Core:2775`, `2776`, `2802`, and — decisive, because it is the *same class* — at `Core:2891` in `DTrigQVar.CheckQuest`, plus `DScript General.nut:404` and `DOC/squirrel_script/samples/T2_samples.nut:269-271`. A missing property yields a null multiparm, `DCheckString(null, …)` returns it unchanged (`Core:1014-1017`), so `if (vars)` at 2932 is false and only the `DPrint` error branch at 2943 runs; neither `DScript.Quest.SubscribeMsg` nor `::Quest.SubscribeMsg` is ever reached. + +### Line 2740 - The fallback for `_Operation` uses `DGetParam` (which runs the value through `DCheckString`) instead of `DGetParamRaw`, so a general `[Script]Operation` written in the class's own documented `_`-operator notation is evaluated once too early and silently turns into garbage. (new finding) + +- **Anchor:** `DGetParamRaw(_script + action + "Operation", DGetParam(_script + "Operation"),DN)` +- **Severity:** P2 +- **Failure scenario:** `_Operation` is looked up with `DGetParamRaw(_script + action + "Operation", DGetParam(_script + "Operation"), DN)`: the action-specific form is fetched raw (correct — `CheckAndCompileExpression` on 2753 needs the un-parsed string), but the general form goes through `DGetParam`, i.e. `DCheckString`. With `DTrapSetQVarOperation="_$gold_+1"` (the `_`-delimited syntax the class comment on 2735 tells you to use) `DCheckString` sees `str[0] == '_'` and takes the `case '_'` branch at `Core:1351`, which immediately calls `CheckAndCompileExpression(this, "$gold_+1")` — the wrong slice, one `_` short — and returns its result. `PrepareSetQVar` then hands that already-collapsed value to `CheckAndCompileExpression` a *second* time on 2753, so the QVar is written with a compiled-then-recompiled string instead of `oldvalue + 1`. Worse, `DTrapSetQVarOperation="5"` (set the QVar to a constant) is converted by `DCheckString` to the *integer* `5`, and `CheckAndCompileExpression`'s first statement `::split(str, "_")` then aborts with `parameter 1 has an invalid type 'integer' ; expected: 'string'` (verified against a Squirrel build). The action-specific `OnOperation`/`OffOperation` forms are unaffected, which is what hides the asymmetry. +- **Verification:** CONFIRMED, with one detail corrected. The asymmetry is real: at `Core:2740` the action form is fetched via `DGetParamRaw` while the eagerly-evaluated general fallback goes through `DGetParam` → `DCheckString` (`Core:1505`). `DCheckString`'s `case '_'` (`Core:1351-1352`) compiles the expression immediately, and numeric strings collapse to integers (`Core:1479-1483`); `PrepareSetQVar` then re-feeds the result to `CheckAndCompileExpression` at 2753, whose first statement `::split(str, "_")` (`Core:677`) throws on any non-string — so `Operation="5"` is a hard error and `_…_` forms are double-evaluated. One sub-claim is wrong but immaterial: `str.slice(1)` in `case '_'` is *not* "one `_` short" — Squirrel's `split` drops empty tokens, so slicing off the leading underscore produces the identical token list. The finding (premature evaluation / double compile / type throw) stands regardless. + +### Line 2887 - `RepeatForCopies(::callee(NAME, NEW, OLD))` silently discards the three arguments (`callee` ignores extra parameters), so with `Copies` set the copy re-invocation calls `CheckQuest` with zero arguments and throws, leaving `_script` stuck on the copy name. (tracked: T-30) + +- **Anchor:** `RepeatForCopies(::callee(NAME, NEW, OLD))` +- **Severity:** P1 +- **Failure scenario:** T-30 records this line as "invokes `CheckQuest` instead of passing it → unbounded recursion", but the actual mechanism is different and worth correcting in the task row: Squirrel's `callee` is registered with `nparamscheck = 0`, so `::callee(NAME, NEW, OLD)` does **not** invoke anything — it returns the calling closure and throws the three arguments away (verified against a Squirrel build: `typeof ::callee(1,2,3)` is `"function"`). Consequently `RepeatForCopies(func, ...)` receives an empty `vargv`, does `vargv.insert(0, this)` and `func.acall([this])` (`Core:1635-1636`), and `CheckQuest(NAME, NEW, OLD)` is entered with no parameters → `wrong number of parameters (1 passed, 4 required)`. With `DTrigQVarCopies="2"` on an object, the very first QVar change aborts `CheckQuest` before any DoOn/DoOff, and because the throw escapes after `RepeatForCopies` already did `_script += 2` (`Core:1626`) without reaching the reset on `Core:1630`, the instance's `_script` is left as `"DTrigQVar2"` — so every later parameter lookup on that instance reads the copy-2 namespace (the T-91 hazard). Fix as the task row says: `RepeatForCopies(::callee(), NAME, NEW, OLD)`. +- **Verification:** CONFIRMED, including the corrected mechanism. Squirrel's `callee` is registered with nparamscheck 0 (no argument check), so `::callee(NAME, NEW, OLD)` returns the calling closure and discards the arguments rather than invoking anything — no unbounded recursion. `RepeatForCopies` (`Core:1619-1640`) then mutates `_script` at 1626, does `vargv.insert(0, this)` / `func.acall(vargv)` at 1635-1636, and re-enters `CheckQuest(NAME, NEW, OLD)` — which has no default values — with zero arguments, a parameter-count throw. The throw unwinds before the `_script` reset at 1630, so the instance stays on the copy namespace (the T-91 hazard). Reachable in the current tree via `script_test` (`OnTest` → `CheckQuest`, 2882-2884) on any object with `DTrigQVarCopies` set. The `(tracked: T-30)` tag is correct; the T-30 row's "unbounded recursion" wording is what should be updated when the fix lands. + +### Line 2888 - `if (NAME != DGetParam(_script + "Name")) return` compares against a single value, so it rejects the `"*"` wildcard subscription and every name except the last of a `+`-list — both of which `OnBeginScript` deliberately registers. (new finding) + +- **Anchor:** `if (NAME != DGetParam(_script + "Name"))` +- **Severity:** P2 +- **Failure scenario:** `DScript.Quest.SubscribeMsg` supports two multi-name modes: `var_name == "*"` stores `Triggers[instance] <- false` (2824-2825) so `QuestChange` calls `CheckQuest` for *every* QVar change (2865-2866), and `OnBeginScript` loops over an array of names, subscribing each (2933-2939). Both then hit 2888, which compares the incoming `NAME` against `DGetParam(_script + "Name")` — a *single* value. (a) Wildcard: `DTrigQVarName="*"` makes `NAME` e.g. `"loot"` while the parameter is `"*"`, so the `!=` is true and `CheckQuest` returns immediately for every change; the wildcard mode can never fire. (b) `+`-list: `DTrigQVarName="+gold+loot"` is fetched here with `returnInArray` defaulting to `false`, and `_FormatForReturn` returns `param.top()` for that case (`Core:220`) — the **last** element only — so a change to `gold` is rejected and only `loot` ever triggers. The stated intent of the line ("Only relevant for copies") is already served by `QuestChange`'s own `vars.find(name)` filter at 2869, so the check is redundant for the single-name case and actively breaks the two multi-name cases. +- **Verification:** CONFIRMED against the registry code: `SubscribeMsg` stores `false` for `"*"` (2824-2825) and `QuestChange` dispatches *every* change to such triggers (2865-2866), while 2888 compares `NAME` to a single `DGetParam` value — for `Name="*"` that value is not even the string `"*"` (DCheckString's `'*'` MetaProperty operator at `Core:1136` mangles it), and for a `+`-list `_FormatForReturn` returns only `param.top()` when `returnInArray` is false (`Core:216-220`). The redundancy argument also checks out: `QuestChange` already filters per-name via `vars.find(name)` at 2869. Reachability caveat: with the 2931 registration bug still in place, only `OnTest` (NAME=null) reaches this line today; the rejection becomes live the moment 2931 is fixed. + +### Line 2888 - The name comparison is case-sensitive, but `DScript.SetQVar` lowercases the QVar name before notifying, so any QVar whose Design Note name is not already all-lowercase never triggers. (new finding) + +- **Anchor:** `NAME != DGetParam(_script + "Name")` +- **Severity:** P2 +- **Failure scenario:** `DScript.SetQVar` does `name = name.tolower()` on entry (`Core:821`) and later notifies with that lowercased name via `DScript.Quest.QuestChange(name, value, old_value)` (`Core:890`); for the integer tiers it calls `::Quest.Set(name, …)` with the lowercased name too, so the engine's own `QuestChange` message also carries the lowercase form into `OnQuestChange` → `bmsg.m_pName`. Meanwhile `SubscribeMsg` stores `var_name` exactly as authored (2834) and 2888 compares `NAME` against the raw Design Note value. So a mission with `DTrapSetQVarName="LootCount"` and `DTrigQVarName="LootCount"` on two objects: the write stores `"lootcount"` and notifies with `"lootcount"`, `vars.find("lootcount")` at 2869 misses the registered `"LootCount"`, and even on the wildcard path 2888 sees `"lootcount" != "LootCount"` and returns. The trigger silently never fires, with no diagnostic. Both 2869's registry lookup and 2888's compare need `.tolower()` normalisation to match the library's convention. +- **Verification:** CONFIRMED. `DScript.SetQVar` lowercases on entry (`Core:821`), notifies `DScript.Quest.QuestChange` with that lowercased name (`Core:890`), and passes the lowercased name to `::Quest.Set` for the integer tiers (`Core:886`), so the engine's `QuestChange` → `OnQuestChange` → `bmsg.m_pName` path also carries lowercase. `SubscribeMsg` stores the authored case unmodified (2834), and both the `vars.find(name)` at 2869 and the `!=` at 2888 are case-sensitive Squirrel string operations. Same reachability caveat as the previous entry: the mismatch becomes observable once the 2931 registration bug is fixed. + +### Line 2801 - `OnSim()` never checks `message().starting`, so the `TrapQVar` initialisation is re-run on sim *shutdown* as well as sim start. (new finding) + +- **Anchor:** `// TODO: IMPORTANT IS THIS REALLY AFTER?` +- **Severity:** P2 +- **Failure scenario:** `sSimMsg` carries a `BOOL starting` field (`DOC/squirrel_script/Custom-API-reference_messages.nut:56-59`) and the engine sends `"Sim"` both when the sim starts and when it stops; the LG sample's own `OnSim` explicitly comments "check that it's a sim start message" before acting (`DOC/squirrel_script/samples/T2_samples.nut:110-114`). `DTrapSetQVar.OnSim` has no such test, and its only guard — `::DHandler.IsDataSet("MissionInizialzed")` — is the misspelled key from T-33 that is never written (`SetData("MissionInitialized")`, `Core:2332`), so it never suppresses anything. Result: an object with `TrapQVar="score:0"` (or any campaign-scoped value) has `InitQVarFromProp()` run again when the player quits the mission or leaves DromEd's game mode, resetting a campaign QVar that the mission had legitimately advanced. On the shutdown pass the value is written back to its initial state after all gameplay is finished, which for `kScalarCampaign`/`kNonScalarCampaign` tiers persists into the next mission. +- **Verification:** CONFIRMED. `OnSim` at `Core:2801-2806` contains no `message().starting` test; `sSimMsg` carries `const BOOL starting` (`DOC/squirrel_script/Custom-API-reference_messages.nut`, sSimMsg block) and the LG sample gates on it explicitly (`T2_samples.nut:109-115`). The only guard reads the misspelled key `"MissionInizialzed"` (2802) which is never written — the handler's mission-init block writes `"MissionInitialized"` (`Core:2332`, the tracked T-33 pair at 2318/2332) — so nothing suppresses the re-run. This finding is correctly tagged new: T-33 covers only the key misspelling (re-run on every load), not the missing `starting` check (run on shutdown). + +### Line 2741 - The guard `if (!var_name || (!_Operation && !doinit))` treats a `doinit` value of `0` as "no value given", so initialising a QVar to zero from the `TrapQVar` property is impossible. (new finding) + +- **Anchor:** `if (!var_name || (!_Operation && !doinit)){` +- **Severity:** P2 +- **Failure scenario:** `InitQVarFromProp` calls `PrepareSetQVar("", event[0])`/`PrepareSetQVar(event[i], event[i+1])` with the *value* in the `doinit` slot, after passing it through `DCheckString` (2783/2794), which converts the numeric string `"0"` to the integer `0` (`Core:1479-1481`). In the init path no `Operation` parameter is normally set, so `_Operation` is null; the guard then evaluates `(!null && !0)` → `(true && true)` → true (verified: `!0` is `true` in Squirrel) and returns the `DPrint("FAILURE: No QVarName or Operation set…")` early exit. So `TrapQVar="score:0"` — the single most likely way to initialise a counter — silently does nothing, while `TrapQVar="score:1"` works. Note that the code *below* is written to handle this correctly (`if (doinit == false)` on 2752 is false for the integer `0`, since `0 == false` is `false` in Squirrel), so only the guard is wrong; it needs to distinguish "argument omitted" from "argument is falsy", e.g. `doinit == false` rather than `!doinit`. +- **Verification:** CONFIRMED. `DCheckString("0")` returns the integer `0` via the numeric case at `Core:1479-1483`, `!0` is true in Squirrel (integer 0 is falsy), and `0 == false` is false (bool is not a numeric type in Squirrel's equality), so the guard at 2741 rejects exactly the value the branch at 2752 would have handled correctly. Cleanest concrete path: `TrapQVar="0"` plus a `Name` parameter → single-value branch 2778-2785 → `PrepareSetQVar("", 0)` → early return at 2742. Note for the pair form `TrapQVar="score:0"`: the value also arrives as integer 0 (via 2794), but the pair *name* travels in `PrepareSetQVar`'s `action` slot, so `var_name` still comes from the `[Script]Name` parameter — the guard bug applies on top of that separate oddity. + +### Line 2758 - The `if (DPrint())` diagnostic branch calls `::DTestTrap.DumpTable`, a class that only exists in the editor-only `DScript_ModdingTools.nut`, so setting the documented `[Script]Debug=1` on a shipped mission turns every QVar write into a thrown error. (new finding) + +- **Anchor:** `::DTestTrap.DumpTable(result)` +- **Severity:** P2 +- **Failure scenario:** `DPrint()` called with no arguments takes `DoPrint = null`, so it overrides `mode` with `DGetParamRaw(GetClassName()+"Debug", false)` and, when `[Script]Debug=1` is set, skips the `dbgMessage` block entirely and `return true`s at `Core:1565` with **no `IsEditor()` gate on that return path**. Control therefore reaches 2756-2759 in a shipped game as well as the editor. `DTestTrap` is declared only in `DScript_ModdingTools.nut` (documented in CLAUDE.md as editor-only tooling not meant to ship), so on a build without that file the bare root lookup `::DTestTrap` fails and Squirrel throws "the index 'DTestTrap' does not exist" — but only when the operation result happens to be a table/array/blob, i.e. exactly the non-scalar QVar tiers this class exists to support. Wave 1 confirmed the identical pattern at `Core:2106` (`DMultiMessage`); this is the second live site, inside the QVar write path, and `Core:880` is a third. +- **Verification:** CONFIRMED. `DPrint()` with no arguments returns `true` at `Core:1565` whenever `[Class]Debug` resolves truthy; the only `IsEditor()` gate in `DPrint` is on the monolog print at 1551, not on the return path, so 2755-2759 executes in-game. `DTestTrap` is declared only in `DScript_ModdingTools.nut:850` (base `DEditorScripts` at line 1 of that file); I checked that neither declaration sits inside that file's `if (IsEditor())` block (which spans only the `NewDarkStuff` list, ~826-845), so the throw requires the *file* to be absent — exactly the "editor tooling stripped for shipping" deployment CLAUDE.md's file map describes — not merely a game build. With the file absent, `::DTestTrap` at 2758 throws for any table/array/blob operation result. `Core:880` verified as the same pattern inside `SetQVar` itself. + +### Line 2790 - The pair loop reads `event[i+1]` without a bounds check, so a `TrapQVar` property with an odd number of `:`/`;` tokens throws instead of being reported as malformed. (new finding) + +- **Anchor:** `for(local i = 0; i < event.len(); i += 2){` +- **Severity:** P2 +- **Failure scenario:** `event = ::split(GetProperty("TrapQVar"), ":;")` drops empty tokens, so `TrapQVar="a:1;b"` (a trailing key with no value — an easy authoring slip, and the exact shape produced by a trailing `;` plus one stray word) yields `["a","1","b"]`, length 3. The loop `for(local i = 0; i < event.len(); i += 2)` runs at `i = 2`, passes the `i < 3` test, and then indexes `event[3]` on 2791, which throws `the index '3' does not exist` (verified against a Squirrel build). Because this runs from `OnSim`, the failure aborts the whole mission-start initialisation for that object — and, since `OnSim` also fires on shutdown (see the 2801 finding), it throws again on the way out. The loop guard should be `i + 1 < event.len()` with a diagnostic for the leftover token. +- **Verification:** CONFIRMED. `::split` drops empty tokens (documented CLAUDE.md gotcha), so `TrapQVar="a:1;b"` yields the 3-element `["a","1","b"]`; the guard `i < event.len()` at `Core:2790` admits `i = 2` and `event[i+1]` at 2791 indexes `event[3]`, which is an out-of-range throw in Squirrel. The loop is reached from `OnSim` → `InitQVarFromProp` (2804 → 2787-2797) with no try/catch anywhere on the path, so the object's mission-start init aborts. + +### Line 2780 - `event[0] == ""` (and the same statement at 2792) uses `==` where `=` was meant, so the documented `""` empty-value form is a no-op and the literal two-quote string is written into the QVar instead. (new finding) + +- **Anchor:** `if (event[0] == "\"\"")` +- **Severity:** P2 +- **Failure scenario:** The intent is clear from the surrounding `if`/`else`: when the property value is the two-character token `""`, store an empty string; otherwise run the token through `DCheckString`. But `event[0] == ""` is a comparison whose result is discarded, not an assignment (verified: after `if (v == "\"\"") v == ""`, `v` is still the 2-character string `""`). Both branches of the `if` therefore leave `event[0]` untouched, and because the `else` (the `DCheckString` call) is skipped, `PrepareSetQVar("", "\"\"")` writes the literal 2-character value `""` into the QVar. So `TrapQVar="playername:\"\""` produces a QVar holding two quote characters rather than an empty string, and anything comparing it against `""` — including the `==` condition path in `DCheckCondition` — never matches. The same defect is duplicated verbatim at 2791-2792 for the multi-pair branch. +- **Verification:** CONFIRMED by direct reading of `Core:2780-2781` (`if (event[0] == "\"\"") event[0] == ""`) and `Core:2791-2792`: the branch body is a comparison used as an expression statement, its result discarded, so the token remains the two-character literal and — because the matching `if` skips the `else`'s `DCheckString` call — is handed to `PrepareSetQVar` uncooked and stored verbatim by `SetQVar`. (Small nit in the heading: the flagged statement is the *body* on 2781/2792; the anchor targets the `if` on 2780 that selects it.) + +### Line 2828 - `if (!Triggers[instance].find(var_name))` treats the legitimate index `0` as "not found", so re-subscribing the first-registered QVar appends a duplicate and `CheckQuest` then runs twice per change. (new finding) + +- **Anchor:** `if (!Triggers[instance].find(var_name))` +- **Severity:** P2 +- **Failure scenario:** Squirrel's `array.find()` returns `null` when absent and the index otherwise, and `0` is falsy — the gotcha CLAUDE.md calls out and T-23 tracks at `Core:453`/`Core:485`; this is a third, untracked site. `Triggers[instance]` is seeded by the `else` branch on 2834 as `[firstName]`, so the first name always sits at index 0. Any second `SubscribeMsg(this, firstName)` for the same instance then computes `find(firstName) == 0`, `!0` is `true`, and the name is appended again. This is reachable through the framework's own `Copies` mechanism: `OnBeginScript` re-enters itself once per copy via `RepeatForCopies(::callee())` on 2944 (same instance, only `_script` changes), and if copy 2 does not define its own `DTrigQVar2Name` the lookup falls back to the same value, so the identical name is registered twice. `QuestChange` then finds it twice — well, `vars.find(name)` returns the first hit, so the visible symptom is the duplicate entry growing the array on every re-registration (and, once the wildcard/`*` handling is fixed, double DoOn/DoOff dispatch). The check should be `.find(var_name) == null`. +- **Verification:** CONFIRMED. `find` returns the index and `0` is falsy — the exact T-23 gotcha class (tracked sites `Core:453`/`485`; this one at `Core:2828` is untracked) — and the first name always occupies index 0 because the `else` at 2833-2834 seeds `Triggers[instance] <- [var_name]`. Re-entry on the same instance is provided by the framework itself: `RepeatForCopies(::callee())` at 2944 re-invokes `OnBeginScript` with only `_script` changed. Reachability caveat: any name reaching `SubscribeMsg` at all currently requires the 2931 registration bug to be fixed first. + +### Line 2968 - `deleted != "[Null]"` compares against the wrong capitalisation of `DeleteQVar`'s `"[null]"` sentinel, so the `Cache` option writes a phantom `qvar_deleted` mission QVar even when nothing was deleted. (new finding) + +- **Anchor:** `deleted != "[Null]"` +- **Severity:** P2 +- **Failure scenario:** `DScript.DeleteQVar` initialises its return value as the lowercase string `"[null]"` (`Core:897`) and only overwrites it if something was actually found. `DTrapDeleteQVar` guards on `"[Null]"` (capital N), which can never equal it — Squirrel string comparison is case-sensitive. So with `DTrapDeleteQVarName="doesnotexist"` and `DTrapDeleteQVarCache=1`, the guard passes, `typeof deleted` is `"string"` (not array/table/blob), and `::DHandler.SetData("qvar_deleted", "[null]")` runs. Because the DScript QVar system stores mission-scalar QVars under exactly the key `"qvar_" + name` (`Core:834`, `Core:714`), this *creates* a QVar literally named `deleted` holding the string `"[null]"`: `_GetQVarType("deleted")` now reports `kScalarMission` and `$deleted` resolves to `"[null]"` forever after. The same guard also fails to filter `DPrint`'s `true`/`null` return when `Name` is unset (`Core:896`). +- **Verification:** CONFIRMED. `DeleteQVar` initialises its return as the lowercase `local temp = "[null]"` (`Core:897`) and returns `DPrint(...)`'s `true`/`null` when no name is given (`Core:895-896`); the guard at `Core:2968` tests `"[Null]"`, which Squirrel's case-sensitive string equality can never match. The phantom-QVar consequence is real: `::DHandler.SetData("qvar_deleted", …)` creates exactly the key `"qvar_" + "deleted"` that `_GetQVarType` (`Core:714`) and `GetQVar`'s kScalarMission case (`Core:732-733`) treat as a mission-scalar QVar named `deleted`. + +### Line 2938 - `if (_DQVarType >= 0)` compares the `Type` parameter against an integer with no type check, so any non-numeric `Type` value — including the framework's own `"[auto]"` marker — aborts `OnBeginScript` with a comparison error. (new finding) + +- **Anchor:** `if (_DQVarType >= 0)` +- **Severity:** P3 +- **Failure scenario:** `eDQVarType.kTypeAuto` is the *string* `"[auto]"` (`Core:100`), and it is the default `Type` for the sibling script `DTrapSetQVar` (`Core:2744`), so an author configuring a set/trigger pair naturally writes `DTrigQVarType="[auto]"`. `DCheckString` returns `"[auto]"` unchanged (no `[` case matches, so it falls through to `_FormatForReturn`), and `"[auto]" >= 0` raises `comparison between '[auto]' and '0'` — verified against a Squirrel build; unlike `null`, strings have no ordering against integers, so this is a hard throw, not a false result. `OnBeginScript` aborts mid-loop, so any names already processed are half-registered (`DScript.Quest.SubscribeMsg` done, `::Quest.SubscribeMsg` not) and `RepeatForCopies`/`base.OnBeginScript()` on 2944-2945 never run. A `typeof _DQVarType == "integer"` pre-check (or reusing `GetQVar`'s own `type == kTypeAuto` idiom from `Core:723`) is needed. +- **Verification:** CONFIRMED. `eDQVarType.kTypeAuto` is the string `"[auto]"` (verified at `Core:100`, exactly as cited) and is DTrapSetQVar's default `Type` (`Core:2744`). `DCheckString` passes `"[auto]"` through unchanged — no `[`-literal case matches, the `DivideAtNext` sub-switch has no `"auto"`, and `str[1] != '|'`, so it falls to `_FormatForReturn(str, …)` at `Core:1095`. Squirrel's relational comparison raises on string-vs-integer (only `null` orders below everything; strings do not), so `"[auto]" >= 0` at 2938 throws after `DScript.Quest.SubscribeMsg` (2936) but before `::Quest.SubscribeMsg` (2939) and before the `RepeatForCopies`/`base.OnBeginScript()` at 2944-2945 — the half-registered state described. Default path is safe: `eQuestDataType.kQuestDataMission` = 0. + +### Line 2830 - The `else` binds to the inner `find()` `if`, not to the `if (Triggers[instance])` it is indented against, so the "trying to overwrite wildcard `*`" warning fires for ordinary duplicate subscriptions while the real wildcard-overwrite case is silently ignored. (new finding) + +- **Anchor:** `Trying to overwrite wildcard * with` +- **Severity:** P3 +- **Failure scenario:** Squirrel attaches a dangling `else` to the nearest unmatched `if`, regardless of indentation (verified against a Squirrel build). So: (a) when `Triggers[instance]` is an array and `var_name` is already present at an index ≥ 1, `!find(var_name)` is false and the `else` runs, printing "DScript QVar FAILURE: Trying to overwrite wildcard * with …" — a wholly misleading diagnostic for what is really a harmless duplicate registration; (b) when `Triggers[instance]` is `false` (the genuine wildcard case the message was written for), the outer `if` is false, the entire inner `if`/`else` is skipped, and the new name is dropped with **no** message at all. Concretely: an object with `DTrigQVarName="*"` whose copy-2 namespace resolves to `DTrigQVarName="gold"` silently loses the `gold` subscription, while an object that merely re-registers its second name gets a scary and wrong FAILURE line in the monolog. +- **Verification:** CONFIRMED. At `Core:2826-2832` only the outer `if (instance in Triggers)` has braces; `if (Triggers[instance])` (2827) and `if (!Triggers[instance].find(var_name))` (2828) are brace-less, and Squirrel's grammar attaches a dangling `else` to the nearest unmatched `if` — here the `find()` one. So the `else`/`print` at 2830-2831 runs when `find` returns a truthy index (a duplicate at position ≥ 1), and the genuine wildcard case (`Triggers[instance]` = `false`, set at 2824-2825) fails the 2827 test and skips the whole inner if/else with no diagnostic. Anchor sits on the `print` at 2831, immediately beside the flagged `else` on 2830. + +## Cleanup items (12) + +- **Line 2770:** `::print("DID BEGIN")` — the whole `OnBeginScript` override (2769-2772) exists only for this unconditional debug print and can be deleted outright, since `base.OnBeginScript()` is all it does otherwise. Tracked as part of T-62. +- **Line 2805:** `::print("DID SIM")` — unconditional, and placed *after* `InitQVarFromProp()` so it does not even bracket the work it is tracing. Tracked as part of T-62. +- **Line 2864:** `print(type(trigger) + typeof vars)` in `DScript.Quest.QuestChange` — unconditional, and it runs once per registered trigger on *every* QVar change, i.e. the hottest line in the QVar notification path. Tracked as part of T-62. +- **Line 2775, 2777, 2779, 2788:** four more unconditional `print()` calls inside `InitQVarFromProp` (`print(GetProperty("TrapQVar") + " Im " + self)`, `print("Len of prop "…)`, `print(event[0])`, `print("0 is+ '"+event[0])`) — none of these are listed in T-62 and none are gated by `DPrint`; the last one also has an unbalanced quote in its literal. +- **Line 2823:** `print("Saving QVar Trigger" + instance)` in `SubscribeMsg` — unconditional, not listed in T-62, and missing a space after "Trigger". +- **Line 2924:** `print("MODE CHANGED")` in `DTrigQVar.OnDarkGameModeChange` — unconditional, not listed in T-62. +- **Line 2746:** `var_name.tolower()` is evaluated on every `PrepareSetQVar` call purely to build a `DPrint` argument that is discarded unless `Debug` is set — an allocation per trap fire on a hot path. Same shape as the T-60 class of problem. +- **Line 2739-2740, 2888:** the nested fallback lookups `DGetParam(_script + "Name")` / `DGetParam(_script + "Operation")` omit the `DN` argument even though `DN` is already a local, forcing a second `userparams()` call each; and both defaults are evaluated eagerly even when the action-specific parameter exists. +- **Line 2912:** `DCheckParameters(userparams(), satisfied)` re-fetches the Design Note although `DN` was captured on 2890 and is passed to `DoOn`/`DoOff` two lines later. +- **Line 2935, 2950:** commented-out `::DHandler.Extern.DQVarHandler.` prefixes left in place above the live `::DScript.Quest.*` calls — the same abandoned indirection that survives as unreachable code at `Core:891` (T-64). Pick one registry and delete the other. +- **Line 2967:** `local type = typeof deleted` shadows the global `type()` function inside `DoOn`; the file uses `::type(...)` elsewhere precisely to avoid this ambiguity. +- **Line 2891:** `::Property.Get(self,"TrapQVar")` is the eagerly-evaluated default of a `DGetParamRaw` call, so the property is read on *every* `CheckQuest` even when `[Script]Condition` is set — and `DTrapSetQVar` reads the same property for a completely different grammar (`name:value;…` pairs), so the two scripts cannot safely share an object. + +## Incomplete items (5) + +- **Line 2801:** Author's own TODO on the handler signature — `function OnSim(){ // TODO: IMPORTANT IS THIS REALLY AFTER?` — the ordering question (is `Sim` guaranteed to arrive after the handler's mission-init block) is unresolved, and the answer determines whether the `MissionInizialzed`/`MissionInitialized` guard (T-33) is even the right mechanism. +- **Line 2789:** `event.apply(::strip) // TODO: Do this more.` — whitespace stripping is applied only in the multi-pair branch, so the single-value branch at 2778-2786 passes unstripped tokens to `DCheckString`, i.e. `TrapQVar="score: 5"` behaves differently from `TrapQVar="a:1; score: 5"`. +- **Line 2922-2928:** `DTrigQVar.OnDarkGameModeChange` is a stub: it tests `!message().suspending && !message().resuming` and then only prints. As written it also *suppresses* the framework's generic `OnMessage` for `DarkGameModeChange` (per the "a specific handler suppresses `OnMessage()`" rule), so `DTrigQVarOn="DarkGameModeChange"` cannot work on this class, and it does not call any base handler. +- **Line 2962-2972:** `DTrapDeleteQVar` implements only `DoOn` — there is no `DoOff`, no `OnTest`, and no `[Script]OffName` support, so a `TurnOff` reaching the trap matches `DefOff` and then falls through to `DBaseTrap.DoOff`'s empty body with no diagnostic. Either document it as an on-only trap or give `DoOff` a meaning. +- **Line 2732, 2877, 2962:** none of the three QVar classes carry the `` doc block that `DBasics`, `DBaseTrap` and `DRelayTrap` all have, and the `Cache` parameter plus the `qvar_deleted` slot are described nowhere. This is the concrete content gap behind T-83 ("Document the QVar system"). + +## Suggestions (7) + +- **Move the `RepeatForCopies` call in `CheckQuest` from the top of the function (2887) to the end, the way every other caller in the framework does it.** — Even with the T-30 argument bug fixed, calling it first is wrong: `RepeatForCopies` mutates `_script` to the next copy name before recursing and the *terminal* copy resets `_script` back to `GetClassName()` before returning (`Core:1626-1633`), so the copy-N frame executes the rest of `CheckQuest` under the *base* namespace. Net effect with `Copies="2"`: copy 1's parameters are used twice and copy 2's never. The comment "Doing this here because of that return down there" points at the real obstacle — the `return SetData(...)` on 2919 — which can be split into a plain `SetData` plus a trailing `return RepeatForCopies(::callee(), NAME, NEW, OLD)`. +- **Normalise QVar names to lowercase at the single point where they enter the registry, rather than at each use.** — `DScript.SetQVar`/`GetQVar`/`DeleteQVar` all lowercase internally, so the trap layer is the only place where the un-normalised form still circulates: `SubscribeMsg` (2834), the `find` at 2869 and the compare at 2888. A `var_name = var_name.tolower()` in `SubscribeMsg` plus `NAME.tolower()` at 2888 removes a whole class of silent no-match failures. +- **Replace the three `find()` truthiness/relational checks in `DScript.Quest` with explicit `== null` tests (2828, 2842, 2869).** — 2828 is an outright bug (see the bug list); 2842 and 2869 happen to survive only because Squirrel orders `null` below every integer, so `null >= 0` is `false` rather than an error (verified). That is an accident of the VM, not a contract, and CLAUDE.md already mandates `== null` for exactly this reason. +- **Rename `DTrigQVar`'s check parameter away from `Condition`.** — `[Script]Condition` / `[Script]OnCondition` / `[Script]OffCondition` are *universal* `DBaseTrap` parameters consumed by `DBaseFunction` (`Core:1805`, `Core:1815`); `DTrigQVar` overloads the same name for its QVar predicate at 2891. Today the collision is masked because `DefOn`/`DefOff` are `null` so `DBaseFunction` matches nothing, but any author who adds `DTrigQVarOn="…"` gets the QVar expression evaluated a second time as a gate. Something like `[Script]Check` would be unambiguous and is still free. +- **Re-check `::Quest.UnsubscribeMsg(self, "*")` in `OnEndScript` (2949) against what the engine actually does.** — The API notes say the `"*"` name is a *subscription of its own* ("you can subscribe to name `*` in order to get QuestChange messages for all qvars (unsubscribe `*` to remove it again)", `Custom-API-reference_services.nut:512-516`), which suggests it removes the wildcard subscription rather than all per-name subscriptions. If so, the per-name subscriptions made on 2939 leak, and `OnEndScript` should unsubscribe each name it registered (the same list `OnBeginScript` walked). +- **Give `DTrapSetQVar` and `DTrigQVar` distinct properties, or make both read `TrapQVar` with the same grammar.** — Both currently read `TrapQVar` (2776/2802 as `name:value;…` init pairs, 2891 as a condition expression). Putting a set-trap and a trigger on one object — the obvious way to build a counter that fires at a threshold — makes one of the two misparse the other's data. +- **Make the "no Operation" early exit at 2742 visible without `Debug`.** — `return DPrint("FAILURE: No QVarName or Operation set…")` only prints when `[Script]Debug` is set, so the most common misconfiguration of this class produces total silence. `kDoPrint` is used for exactly this elsewhere (e.g. 2943); the inline comment "This could be wanted" argues for keeping it quiet, but then the message should not say FAILURE. diff --git a/docs/review/wave2/sfx-hud.md b/docs/review/wave2/sfx-hud.md new file mode 100644 index 0000000..9095a8f --- /dev/null +++ b/docs/review/wave2/sfx-hud.md @@ -0,0 +1,73 @@ +# DHudObject + DHudCompass + +**File:** `DScript SFX.nut` · **Anchor:** line 398 + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +`DHudObject` (398-536) creates or borrows an object, detail-attaches it to the player's camera +submodel, and repositions/reorients it every mid-frame via `::DHandler`'s `PerMidFrame_*` +registry; `DHudCompass` (539-568) is a thin override that only changes the rotation math and the +default `{Object}`/`{Rotation}` values. The happy path — a single `DHudCompass` activated once per +mission and never toggled or saved mid-active — genuinely works: the registration dance with +`::DHandler`, the shared, once-per-frame `Object.CalcRelTransform` call in `Core:2410` (the file's +half of tracked `T-39`), and the reload-restore branch in `OnBeginScript` are all coherent for that +one path. Outside that path the class breaks in three independent, concrete ways found while +tracing exactly the lifecycle the assignment calls for: reloading a save while a `DHudCompass` is +active throws immediately because `DHudObject.OnBeginScript` calls the polymorphic `DoOn` with +three arguments but `DHudCompass.DoOn` only accepts two; activating the default (undocumented +`{Object}` omitted, which is the class's own documented common case) throws under System Shock 2 +because it unconditionally calls the Thief-only `DarkUI.InvItem()`; and toggling any single +`PerMidFrame_*` consumer fully off and back on within one play session (which is exactly what +`DHudObject.DoOn`'s own built-in re-frob-to-toggle-off logic invites) permanently detaches the +`FrameUpdater` overlay from `gGameOverlay` for the *entire* `PerMidFrame` subsystem, not just the +object being toggled, until the next save/reload happens to repair it as a side effect. None of the +three restate the file-level `T-39` coupling note already in scope; all three are new. Beyond those, +the class also repeats the `GetClassName()`-instead-of-`_script` mistake CLAUDE.md warns against for +its `Rotation`/`Spin` parameters, quietly breaking `Copies` for this one class. + +## Confirmed bugs (4) + +### Line 436 - `DHudObject.OnBeginScript`'s reload path calls the virtually-dispatched `DoOn` with three arguments, but `DHudCompass.DoOn` (the class's own direct subclass) only declares two, so reloading a save with an active `DHudCompass` throws instead of restoring it. (new finding) + +- **Anchor:** `DoOn(userparams(), null, true)` +- **Severity:** P1 +- **Failure scenario:** `DHudObject.OnBeginScript` (417-440) is inherited unmodified by `DHudCompass` (it isn't overridden there), so on every script reconstruction (i.e. every save-game load, per CLAUDE.md's "instances are destroyed and recreated on save/load") the base class's `if (IsDataSet("Active")) { ...; DoOn(userparams(), null, true); ... }` runs with `this` bound to whatever concrete class is in play. Since Squirrel dispatches unqualified method calls virtually, on a `DHudCompass` instance this calls `DHudCompass.DoOn(DN, onreload = null)` (line 560) — a closure declared with exactly two formal parameters — with three positional arguments. Squirrel only tolerates *fewer* arguments than declared formals (defaults fill the gap); supplying *more* than the formal count with no `...` vararg present is a hard "wrong number of parameters" runtime error, thrown before a single line of `DHudCompass.DoOn`'s body executes. Concretely: a player activates a `DHudCompass` (any config), then saves and reloads (or the mission auto-saves) while it is still active — `IsDataSet("Active")` is true, `OnBeginScript` calls the 3-arg form, and the object's script throws on every subsequent load instead of restoring `AttachLink`/rotation state and re-registering for `PerMidFrame` updates; `base.OnBeginScript()` on the following line and the `PerMidFrame_ReRegister` call immediately before it never run either, since the exception unwinds out of `OnBeginScript` first. +- **Verification:** Confirmed by re-tracing. `DHudObject.OnBeginScript` (SFX:431-440) calls `DoOn(userparams(), null, true)` at SFX:436 when `IsDataSet("Active")`; `DHudCompass` (SFX:539-568) defines no `OnBeginScript` of its own, so it inherits this path, and its `DoOn` override at SFX:560 declares exactly `(DN, onreload = null)` with no `...` vararg. `DHudCompass` does set `"Active"` in normal use (its `DoOn` calls `base.DoOn(DN, null, onreload)` at SFX:565, which reaches `SetData("Active")` at SFX:524), so the reload precondition is reachable. Squirrel 3 raises a "wrong number of parameters" runtime error when a non-vararg closure receives more arguments than it declares (defaults only cover *missing* args), so the 3-arg virtual dispatch onto the 2-parameter override throws before SFX:437-439 run. Anchor verified at SFX:436. + +### Line 519 - `DHudObject.DoOn`'s default-item lookup unconditionally calls the Thief-only `DarkUI.InvItem()`, so activating a `DHudObject`/`DHudCompass` with no explicit `{Object}` parameter — the class's own documented default behaviour — throws under System Shock 2. (new finding) + +- **Anchor:** `item = DGetParam(_script, DarkUI.InvItem(), DN)` +- **Severity:** P1 +- **Failure scenario:** `DHudCompass`'s own class doc (400-404) states its default is "the selected inventory item", i.e. the common case is to *not* set `{Object}` in the Design Note at all. `DarkUI.InvItem()` is passed as the second (default-value) argument to `DGetParam`; Squirrel evaluates call arguments eagerly, so `DarkUI.InvItem()` executes regardless of whether `_script` is actually present in the Design Note. `Custom-API-reference_services.nut:624-627` groups `DarkGame`/`DarkUI` under an explicit `// THIEF SERVICES` header, and the framework itself already knows this exact fact — `DScript Core.nut:1050-1051`'s `[item]`/`[weapon]` operators both branch on `GetDarkGame() != 1` before touching `DarkUI`, using `ShockGame.GetSelectedObj()` under SS2. `DHudObject`/`DHudCompass` (398-568) sit above the file's own `if (GetDarkGame() != 1){ ... }` gate that starts at line 570 (which explicitly exists because, per its comment, "DInventoryMaster, DSubInventory, DInventoryDummy and DUseInventoryMaster are Thief only, SS has a nice management system") — i.e. this class was written to be usable in both games, but line 519 was not updated to match. Under SS2 (`GetDarkGame() == 1`), activating a `DHudCompass` (or plain `DHudObject`) without an explicit `{Object}` parameter throws "the index 'DarkUI' does not exist" the instant `DoOn` runs, instead of picking up the currently-selected inventory item. +- **Verification:** Confirmed. `DOC/squirrel_script/Custom-API-reference_services.nut:624` places `DarkUI` (declared at `:659`) under the `// THIEF SERVICES` header, and the framework's own `[item]`/`[weapon]` operators at `DScript Core.nut:1050-1051` guard every `::DarkUI.InvItem()`/`InvWeapon()` call behind `(::GetDarkGame() != 1)`, falling back to `ShockGame.GetSelectedObj()`/`PlayerGun()` under SS2 — proof the author treats `DarkUI` as absent there. A grep of `DScript SFX.nut` shows the SS2 gate `if (GetDarkGame() != 1){` opens only at SFX:570, *after* `DHudObject`/`DHudCompass` (398-568), and there is no earlier gate, so both classes compile and run under SS2. Because Squirrel evaluates call arguments eagerly, `DarkUI.InvItem()` at SFX:519 executes on every `DoOn` reaching that line (the `if (!item)` guard at SFX:518 is satisfied in the framework's normal `DoOn(DN)` dispatch), throwing under SS2 even before `DGetParam` could consult the Design Note. Anchor verified at SFX:519. + +### Line 2416 (`DScript Core.nut`) / Line 2493 (`DScript Core.nut`) - After the last `PerMidFrame_*` consumer deregisters, `NewOverlay` silently refuses to re-attach a fresh `FrameUpdater` to `gGameOverlay` on the next registration, because the stale `OverlayHandlers.FrameUpdater` table entry from the deregistered run is never cleared — freezing the entire `PerMidFrame` subsystem, not just the one object being toggled, until the next save/reload. (new finding) + +- **Anchor:** `::gGameOverlay.RemoveHandler(OverlayHandlers.FrameUpdater);` +- **Severity:** P1 +- **Failure scenario:** `DHudObject.DoOn` (503-527) itself invites this: its own toggle logic (`if (IsDataSet("Active") && !onreload){return DoOff(DN)}`, line 505-506) means re-frobbing an already-active compass calls `DoOff`, which calls `::DHandler.PerMidFrame_DeRegister(this)` (`SFX:530`). When that was the *only* registered `PerMidFrame` instance, `PerMidFrame_DeRegister` (`Core:2416-2423`) clears `PerMidFrame_database`'s length to zero, `ClearData("PerMidFrame_Active")`, and calls `::gGameOverlay.RemoveHandler(OverlayHandlers.FrameUpdater)` — but it never deletes the `"FrameUpdater"` key out of the `OverlayHandlers` table itself, only detaches it from the live overlay list. The next time anything calls `::DHandler.PerMidFrame_Register(...)` (e.g. re-frobbing the same compass back on), `PerMidFrame_Active` is unset so it takes the "first registrant" branch and calls `NewOverlay("FrameUpdater", ::cDHandlerFrameUpdater)` (`Core:2430-2447`) again — but `NewOverlay` (`Core:2480-2498`) sees `"FrameUpdater" in OverlayHandlers` still true from before, `multiple` defaults `false`, and hits `else return` (line 2493) *before* reaching its own trailing `::gGameOverlay.AddHandler(OverlayHandlers[Name])` (line 2497). `PerMidFrame_database`/`PerMidFrame_Active` are populated again as if registration succeeded, but the actual `cDHandlerFrameUpdater` overlay handler stays detached from `gGameOverlay`, so its `DrawHUD()` (which is the only thing that ever calls `PerMidFrame_DoUpdates()`, per `DScript Overlays.nut:125-126`) never fires again for *any* `PerMidFrame` consumer — not just the compass — until a save/reload happens to rebuild `OverlayHandlers.FrameUpdater` from scratch via `DScriptHandler.OnBeginScript` (`Core:2309-2314`), which is the only code path that unconditionally re-`AddHandler`s everything. +- **Verification:** Confirmed by tracing all four hops in the current tree. (1) `PerMidFrame_DeRegister` (`Core:2416-2423`) deletes the database key and, when the database is empty, does `ClearData("PerMidFrame_Active")` + `::gGameOverlay.RemoveHandler(OverlayHandlers.FrameUpdater)` — the `OverlayHandlers` table entry is never deleted. (2) The next `PerMidFrame_Register` (`Core:2430-2447`) takes the first-registrant branch (data flag was cleared) and calls `NewOverlay("FrameUpdater", ::cDHandlerFrameUpdater)` at `Core:2442`. (3) `NewOverlay` (`Core:2480-2498`) finds `"FrameUpdater" in OverlayHandlers` true, `multiple` false, and hits `else return` at `Core:2493`, never reaching `::gGameOverlay.AddHandler(...)` at `Core:2497` — tellingly, the commented-out predecessor code at `Core:2436-2441` handled exactly this "already in OverlayHandlers" case correctly before the `NewOverlay` refactor. (4) `PerMidFrame_DoUpdates` is invoked solely from `cDHandlerFrameUpdater.DrawHUD` (`DScript Overlays.nut:125-126`), so with the handler detached no `PerMidFrame` consumer updates until `DScriptHandler.OnBeginScript` (`Core:2309-2314`) rebuilds and re-adds `FrameUpdater` on the next reload. Anchor verified beside `Core:2416` (the `RemoveHandler` line is `Core:2421`); the triggering call `::DHandler.PerMidFrame_DeRegister(this)` is SFX:530. + +### Line 508 / Line 512 - `DHudObject.DoOn` reads its `Rotation`/`Spin` Design Note parameters off `GetClassName()` instead of `_script`, silently breaking per-copy configuration under `Copies` for this one class while every other parameter in the same function correctly uses `_script`. (new finding) + +- **Anchor:** `rot_offset = DGetParam(GetClassName() + "Rotation", vector(0,0,0), DN)` +- **Severity:** P2 +- **Failure scenario:** CLAUDE.md's parameter convention is explicit: "Always build parameter names as `_script + "Foo"`, never a hard-coded string" — and `_script` is exactly what distinguishes copy 2..9 (`"DHudObject2"`, etc.) from copy 1 (`"DHudObject"`) under the framework's `Copies` mechanism. Lines 519-521 in the very same function correctly use `_script` (`DGetParam(_script, ...)`, `DGetParam(_script+"UseDummy", ...)`, `DGetParam(_script + "MaxSize", ...)`), but lines 508 and 512 use `GetClassName()`, which is always the bare class name regardless of which numbered copy is executing. For a Design Note using `Copies="3"` with per-copy `DHudObject2Rotation="0,0,45"` / `DHudObject3Rotation="0,0,90"` intending three differently-rotated HUD attachments, every copy instead reads the shared, copy-1-only `DHudObjectRotation`/`DHudObjectSpin` keys (or the hardcoded defaults if that key is absent too), so copies 2 and 3 silently get copy 1's rotation/spin instead of their own. (`DHudCompass.DoOn`, line 561, pre-populates `rot_offset` correctly via `_script` before calling `base.DoOn`, which happens to make the base's own buggy `Rotation` lookup at line 508 dead code for that subclass specifically — but the `Spin` lookup at line 512 is not similarly guarded and still fires unconditionally for both `DHudObject` and `DHudCompass`.) +- **Verification:** Confirmed. `RepeatForCopies` (`Core:1619-1640`) is what implements `Copies`: it mutates `_script` to `GetClassName() + ` (`Core:1626, 1633`) and re-invokes the caller, and `DBaseFunction` ends with `RepeatForCopies(::callee(), DN)` (`Core:1834`), so `DoOn` genuinely re-runs once per copy with only `_script` changed — `GetClassName()` (engine `SqRootScript` member; `DHudObject` is not a `DTrigger`, so the `Core:2152` override does not apply) always returns the bare class name. Hence SFX:508/512 read the copy-1 key for every copy while SFX:519-521 correctly use `_script`. Verified nuance for `Rotation`: the `if (!rot_offset)` guard at SFX:507 means the value is also cached after the first copy's `DoOn`, so per-copy values are doubly unreachable; `SpinBase` (SFX:512) has no such cache and is re-read every copy, but always from the shared `GetClassName()` key. Anchor verified at SFX:508. + +## Cleanup items (2) + +- **Line 415** (`pos_vector = vector()`): This class-level default is a `vector` — a native Squirrel class instance, i.e. a reference type per CLAUDE.md's "class member defaults that are tables/arrays are shared between instances" gotcha — and it is never reassigned to a fresh per-instance object anywhere in this class (contrast `loc_offset`, which `OnCalcLocOffset` reassigns to a new vector at line 457, giving each instance its own copy). That makes `pos_vector` a de facto single object shared by every `DHudObject`/`DHudCompass` instance that ever exists, which is exactly what lets `Core:2410`'s `Object.CalcRelTransform(::PlayerID, ::PlayerID, DHudObject.pos_vector, vector(), 4, 0)` (the file-half of tracked `T-39`) write one shared result that every registered instance's `FrameUpdate` (line 476) then reads. It happens to be correct today only because that particular `CalcRelTransform` call is itself object-independent (always player-vs-player-submodel-0); nothing in the code documents that this is why the sharing is safe, so a future edit that made the position calculation instance-specific (e.g. a `DHudModelObject`-style variant with its own offset) would silently corrupt every other active instance's position via this same shared object, with no compiler or runtime signal that anything had changed. +- **Line 546** (`*/#######################################`): The `DHudCompass` docstring's closing `*/` is immediately followed on the same line by a run of `#` characters, which Squirrel parses as a *second*, separate line-comment rather than part of the block-comment closer or a following fold banner — harmless as written, but visually merges the doc-comment terminator with the fold marker in a way that differs from every other class's separate `*/` + banner-on-its-own-line convention used elsewhere in this file (e.g. line 406-408). + +## Incomplete items (1) + +- **Line 505** (`if (IsDataSet("Active") && !onreload){return DoOff(DN)}`): The inline comment marks this as `// TODO: Make toggle optional` — the author's own acknowledgement that there's currently no way to configure a `DHudObject`/`DHudCompass` to *not* toggle off when re-triggered (e.g. re-selecting the same inventory item while the compass is already showing), only the always-on toggle behaviour implemented here. + +## Suggestions (3) + +- **Have `PerMidFrame_DeRegister` (`Core:2416-2423`) `delete OverlayHandlers.FrameUpdater` (not just call `RemoveHandler` on it) whenever it clears `PerMidFrame_Active`, so the next `PerMidFrame_Register` sees a clean slate and `NewOverlay` actually re-adds a working handler instead of silently no-oping.** This is the direct fix for the third bug above and mirrors the fact that `PerMidFrame_database` itself is thrown away in the same branch — `OverlayHandlers.FrameUpdater` should be torn down symmetrically rather than left as a dangling, detached instance. +- **Route `DHudObject.DoOn`'s default-item lookup through the same `GetDarkGame() != 1` branch already used at `Core:1050` (`DarkUI.InvItem()` vs `ShockGame.GetSelectedObj()`), rather than hardcoding the Thief-only call.** The fix is a one-line, already-precedented pattern elsewhere in the same codebase; it would make the class's own documented default ("uses the selected inventory item") actually true under SS2 instead of only under Thief. +- **Give `DHudObject.OnBeginScript`'s reload path a signature-stable way to re-invoke `DoOn` — e.g. always calling `base.DoOn(DN, null, true)` explicitly instead of the bare virtual `DoOn(...)`, or standardizing every subclass override of `DoOn` to keep the same `(DN, item, onreload)` shape (as `DHudCompass` almost does, just missing the middle parameter).** Either removes the arity trap for this class and for any future subclass that overrides `DoOn` with a different parameter count, since the reload path calling convention is otherwise an undocumented contract between `DHudObject` and every one of its subclasses. diff --git a/docs/review/wave2/sfx-inventory.md b/docs/review/wave2/sfx-inventory.md new file mode 100644 index 0000000..555c02b --- /dev/null +++ b/docs/review/wave2/sfx-inventory.md @@ -0,0 +1,155 @@ +# SFX — Inventory (DInventoryMaster, DSubInventory, DInventoryDummy, DUseInventoryMaster, LootSounds, DRenameItem) + +**File:** `DScript SFX.nut` · **Anchor:** line 574 (`class DInventoryMaster`), line 952 (`class DRenameItem`) + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +**Method note:** This unit was reviewed directly by the session model (Fable) after the subagent +review failed twice on API 529 overload. Review and verification were a single pass by a single +model — each "Verification" bullet is a self-check trace against the code and the engine reference, +not an independent adversarial pass. + +## Overall assessment + +The world-inventory display core (holder creation, per-category dummy placement, distance auto- +close) is workable and the deliberate shared-vector reuse (`pos`/`rot` class members, reset at +lines 730–732) is internally consistent. The failure modes cluster at the seams: DoOn has no +"already open" guard for its three On-messages, so refocusing duplicates the entire dummy set; +DInventoryDummy hard-wires the master's handler, which crashes or misbehaves for pure-DSubInventory +setups; and DUseInventoryMaster's "auto" routing throws on the no-match path. LootSounds came out +clean apart from the engine-gated wrapper (`GetDarkGame() != 1 && kDisplayTotalLoot`, line 904 — +note the whole DInventory block is likewise Thief-only via line 570's `if (GetDarkGame() != 1){`). +DRenameItem's rename-with-language-fallback works, but its countdown machinery does not survive a +TurnOff, corrupts its name backup on re-trigger, and (with Copies) leaves `_script` mangled — the +T-91 hazard realized. Tracked rows T-17 and T-75 re-confirmed at their locations; not restated. + +## Confirmed bugs (6) + +### Line 765 — DInventoryMaster.DoOn re-runs Update() while the holder is already open, duplicating every dummy object (new finding) + +- **Anchor:** `DefOn = "+InvSelect+FrobInvEnd+InvFocus"` +- **Severity:** P2 +- **Failure scenario:** Only `FrobInvEnd` has a toggle guard (line 756). `InvSelect` and + `InvFocus` fall straight through: CreateHolder() returns the existing holder (line 609–610), and + Update() creates a full second set of dummy objects for every carried item — nothing in Update() + destroys or reuses the previous set. Cycling focus over the master item while the display is + open (routine inventory interaction) stacks a complete duplicate world-inventory each time, with + overlapping models and doubled overlay entries (line 736 appends to the overlay list which was + cleared only once per Update call). +- **Verification:** Update() (633–739) contains creations only; the sole destruction in the class + is DoOff's `Object.Destroy(ClearData("DInvAttacher"))` (774), which removes the holder (and its + attachments) wholesale. No data slot records created dummies; no guard checks + `IsDataSet("DInvAttacher")` for the InvSelect/InvFocus paths. + +### Line 817 — DInventoryDummy always calls the DInventoryMaster handler's DoOff — crashes when only DSubInventory exists, and closes the wrong inventory otherwise (new finding) + +- **Anchor:** `::DHandler.Extern.DInventoryMaster.DoOff()` +- **Severity:** P2 +- **Failure scenario:** Dummies are created by both DInventoryMaster and DSubInventory (shared + Update()), but the frob handler dereferences `Extern.DInventoryMaster` unconditionally. + (a) A mission using only DSubInventory never registers that key (registration is gated on + `GetClassName() == "DInventoryMaster"`, line 594–596), so frobbing any sub-inventory dummy + throws `the index 'DInventoryMaster' does not exist`. (b) With both present, frobbing a + *sub*-inventory dummy closes the *master's* holder while the sub's own holder and dummies stay + in the world until the 1-second distance timer notices. +- **Verification:** Registration site traced (593–599: master only); DSubInventory registers under + `"SubInv" + Name` instead (786). The dummy has no back-link to its creating instance — the + ScriptParams link at 661 points at the *item*, not the inventory script — so the class cannot + currently do better without a design change. + +### Line 853 — DUseInventoryMaster.GetInventory throws when DUseSubInventory="auto" matches no registered sub-inventory (new finding) + +- **Anchor:** `if (sub <= OBJ_NULL){ // In case it's not found or an archetype.` +- **Severity:** P2 +- **Failure scenario:** In the "auto" branch (835–842) `sub` is only reassigned on a successful + archetype match; when the foreach finds nothing, `sub` is still the **string** `"auto"`, and + `"auto" <= OBJ_NULL` is a string-vs-integer relational comparison — Squirrel raises + `comparison between two incompatible types` (unlike `==`, relational operators do not accept + mixed types; only null gets the special less-than-everything treatment). GetInventory is called + from OnContained on every pickup (867), so picking up any item tagged auto with no matching + sub-inventory in the mission throws instead of falling back to the master. +- **Verification:** Traced both assignments in the auto branch — `return sub = entry.self` + (839) exits the function on match, so the post-loop fall-through provably still holds the + string. Squirrel's ObjCmp semantics double-checked: mixed non-numeric, non-null comparison + raises; the author's own `#NOTE null < anything = true` (line 1035) covers only null. + +### Line 1020 — DRenameItem.DoOff restores the name once but the running countdown timer immediately re-applies the hack and keeps ticking (new finding) + +- **Anchor:** `function DoOff(DN){` +- **Severity:** P2 +- **Failure scenario:** The `[Timer]` mode arms a self-perpetuating 1-second chain + (DSetTimerData, 1008/1044) keyed off the `_script + "Ticks"` data slot. DoOff neither kills the + pending timer nor clears "Ticks", so after a TurnOff the next tick finds Ticks > 1, calls + `RenameItemHack` again (1043) and re-arms (1044). The item's name flashes back to the countdown + one second after being restored and the countdown runs to zero anyway, then fires its TOff + messages (1038) as if never cancelled. +- **Verification:** DoOff (1020–1025) touches only GameName/OrgName. OnTimer's only exits are + `append == 0` (1034) and the missing-name guard — there is no cancelled-state check. The data + slot and timer chain survive DoOff by simple omission. + +### Line 999 — DRenameItem overwrites its original-name backup with the already-hacked name on every re-trigger (new finding) + +- **Anchor:** `SetData(_script+"OrgName", Property.Get(item,"GameName"))` +- **Severity:** P2 +- **Failure scenario:** First TurnOn: GameName unset → no backup → fine (DoOff removes the + property, archetype name returns). But ReplaceItemNameFromRes *sets* GameName (967), so on any + second TurnOn `Property.PossessedSimple` (998) is true and the backup slot is overwritten with + the current — hacked — name. A later TurnOff then "restores" the hacked name; the real original + is unrecoverable. +- **Verification:** Traced the property lifecycle: 967 sets `"Name_" + newname` unconditionally + before the language check; RenameItemHack (Core:264) also writes GameName. Either path leaves + the property possessed, so the 998 condition flips permanently after the first run and the + backup semantics invert exactly as described. + +### Line 1039 — DRenameItem.OnTimer's countdown-finished path returns without restoring `_script`, corrupting all later parameter lookups on that copy (tracked: T-91) + +- **Anchor:** `_script = data[0]` +- **Severity:** P2 +- **Failure scenario:** OnTimer assumes the identity stored in the timer payload (1032) and only + restores it at line 1046 — which the `return` at 1039 (countdown reached zero) skips. Without + Copies, `data[0]` equals the class name and nothing is harmed; with `Copies` ≥ 2 the instance is + left permanently impersonating e.g. `DRenameItem2`, so every subsequent Design-Note lookup on + that object reads the wrong parameter namespace — the exact instance-corruption hazard T-91 + tracks framework-wide, realized here. +- **Verification:** Control flow is linear: 1032 assigns, 1034–1039 early-returns on the terminal + tick (after DoOff and TriggerMessages, both of which *rely* on the assumed `_script` — that part + is intended), 1046 restores only on the non-terminal path. No other reset exists; DBaseTrap + never re-derives `_script` after construction. + +## Cleanup items (4) + +- **Line 767** (`SetOneShotTimer("DCheckDistance", 1)`): every DoOn arms another self-rescheduling + distance-check chain; with the InvFocus retrigger issue above, several chains tick concurrently + while the display is open. Arm only when not already active. +- **Line 774** (`::Object.Destroy(ClearData("DInvAttacher"))`): DoOff on an already-closed + inventory (InvDeSelect is the DefOff) passes null into Object.Destroy; harmless today but worth + a one-line guard. +- **Line 869** (`print("Hi I'm a " + …)`): dev print inside `if (DPrint(""))` in + DUseInventoryMaster.OnContained — same leftover class as T-60/T-63. +- **Line 786** (`::DHandler.RegisterExternHandler("SubInv" + DGetParam(_script + "Name"),this)`): + registrations are never removed; a destroyed sub-inventory leaves a stale handler entry whose + `.self` is a dead object id. + +## Incomplete items (2) + +- **Line 794** (`/*function OnContainer(){`): DSubInventory auto-remove-when-empty implemented + then discontinued — tracked T-75 (accepted, listed for completeness). +- **Line 1052** (`//#NOTE: FIX: Items with stacks get copied when dropped…`): OnCreate carries the + author's own note that the stack-copy fix is partial — and its body is dead until tracked T-17 + (undefined `DN` at line 1055) is fixed. + +## Suggestions (4) + +- **Gate DoOn on `IsDataSet("DInvAttacher")` for InvSelect/InvFocus (reposition/refresh instead of + re-create), or make Update() destroy its previous dummy set first** — one guard removes the + duplication bug and the timer stacking. +- **Give dummies a ScriptParams-style back-link (or link data tag) to their creating inventory + instance and have DInventoryDummy message that instance instead of hard-wiring + `Extern.DInventoryMaster`** — fixes both halves of the dummy-frob bug and keeps subs + self-contained. +- **In GetInventory, initialize the auto branch's fallback explicitly (`sub = OBJ_NULL` when the + loop finds nothing)** — restores the intended master fallback and removes the type-mismatch + throw. +- **Make DRenameItem.DoOff clear `_script + "Ticks"`** — OnTimer's `GetData` then naturally + terminates the chain next tick (or add an explicit cancelled check); pair with moving the + `_script` restore above the terminal `return`. diff --git a/docs/review/wave2/sfx-ray-camera.md b/docs/review/wave2/sfx-ray-camera.md new file mode 100644 index 0000000..c5933ab --- /dev/null +++ b/docs/review/wave2/sfx-ray-camera.md @@ -0,0 +1,238 @@ +# SFX — Ray & Camera (DRay, DArmAttachment, DObjectFaceTarget, DObjectPanTo, DDirector) + +**File:** `DScript SFX.nut` · **Anchor:** line 2 (`class DRay`), line 1425 (`class DDirector`) + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +**Method note:** This unit was reviewed directly by the session model (Fable) after the subagent +review of this unit failed twice on API 529 overload. Review and verification were a single pass by +a single model — each "Verification" bullet is a self-check trace against the code and the engine +reference (`DOC/squirrel_script/Custom-API-reference*.nut`), not an independent adversarial pass. + +## Overall assessment + +DRay's core DoOn geometry (bounding-box/lifetime scaling, facing trigonometry) is coherent, but its +link bookkeeping is broken in both directions: DoOn crashes on re-trigger for the default +name-typed `SFX` parameter and on any unrelated `ScriptParams` link, and DoOff destroys only the +bookkeeping link while the actual particle object survives forever (the destroy call sits inside a +comment). DArmAttachment works once but leaks one attached dummy per InvSelect. DObjectFaceTarget +is clean. DObjectPanTo's per-frame mode works, but its float-interval timer mode has a broken data +slot (tracked T-35) with worse consequences than the tracked row describes, and dies permanently +after a save/load. DDirector is the least finished: beyond the tracked dev prints (T-63) and the +forced `true ||` branch (T-66), it crashes on empty ScriptParams data, on a speed-0 jump near the +path end, and on any TurnOff received before the first TurnOn. Known tracked rows T-32, T-49, T-50, +T-51, T-63, T-66, T-73, T-74 were re-confirmed at their locations and are not restated as entries +below. + +## Confirmed bugs (12) + +### Line 52 — DRay.DoOn re-trigger throws for a name-typed SFX parameter, including the default "ParticleBeam" (new finding) + +- **Anchor:** `if (data[1].tointeger() == sfx){` +- **Severity:** P1 +- **Failure scenario:** First TurnOn stores link data `"DRay+ParticleBeam+"` (line 63, + `sfx` is the string `"ParticleBeam"` — DCheckString returns plain non-numeric strings unchanged, + Core:1487). Second TurnOn re-reads that link and calls `data[1].tointeger()` on + `"ParticleBeam"`; Squirrel's `string.tointeger()` throws `cannot convert the string` for + non-numeric input, so every re-trigger (the class's whole "update the effect" purpose, per the + `//Checking if a SFX is already present or if it should be updated` comment at line 48) aborts + with an error. +- **Verification:** Traced write site (line 63 concatenates the loop variable `sfx` verbatim) and + read site (line 52). Squirrel 3's string `tointeger` delegate raises on unparseable strings + (str2num failure path), unlike C `strtol`. The only non-crashing path is an SFX given as a + numeric object ID. Since the parameter default is the string `"ParticleBeam"` (line 28), the + default configuration crashes on the second TurnOn. + +### Line 51 — DRay.DoOn parses every ScriptParams link from the From object as its own, crashing on unrelated links (new finding) + +- **Anchor:** `local data = split(::LinkTools.LinkGetData(link, ""), "+") //See below. SFX Type and created SFX ObjID is saved` +- **Severity:** P2 +- **Failure scenario:** The loop at lines 49–57 takes **all** `ScriptParams` links from `from` and + immediately indexes `data[1]`/`data[2]`. `ScriptParams` is the framework's general-purpose + flavor (DPortal destinations, DInventoryMaster holder links, DDirector waypoint speeds, …). Any + such link whose data contains no `+` yields a 1-element array and `data[1]` throws index out of + range; non-string data makes `split()` itself throw. DoOff (line 135) shows the intended guard — + `data[0] == "DRay"` — which DoOn never applies. +- **Verification:** Confirmed by comparing the two loops: DoOff filters on `data[0] == "DRay"` + before touching anything else; DoOn dereferences `data[1]` with no filter and no length check. + Repo-wide grep confirms multiple other scripts create `ScriptParams` links from arbitrary + objects, so the collision is realistic, not hypothetical. + +### Line 136 — DRay.DoOff never destroys the created SFX object — the Object.Destroy call is inside a commented-out debug print (new finding) + +- **Anchor:** `//DEBUG print("destroy: "+data[2]+" "+Object.Destroy(data[2].tointeger()))` +- **Severity:** P1 +- **Failure scenario:** TurnOff destroys the bookkeeping link (line 137) but the particle object + created at line 61 stays in the world and keeps emitting. Worse, because the link is gone, the + next TurnOn no longer finds the old effect and creates a second one — repeated On/Off cycles + accumulate live particle systems. +- **Verification:** The only `Object.Destroy` in the class sits inside the `//DEBUG` comment at + line 136; the active statement is `::Link.Destroy(link)` alone. No other code path (timer, + handler) touches the created object. Static conclusion is unambiguous. + +### Line 130 — DRay.DoOff hard-codes "DRayFrom" instead of `_script + "From"`, breaking the Copies parameter (new finding) + +- **Anchor:** `foreach (from in DGetParam("DRayFrom",self,DN,kReturnArray))` +- **Severity:** P2 +- **Failure scenario:** With `Copies` ≥ 2, `_script` becomes `DRay2…9` and DoOn reads + `DRay2From`, but DoOff always reads `DRayFrom` — the copy's Off action looks at the wrong (or a + missing) parameter and cleans up the wrong set. Same defect class as tracked T-48 + (`DStackToQVar`), new site. +- **Verification:** DoOn (line 24) uses `_script + "From"`; DoOff uses the literal. CLAUDE.md's + parameter convention section documents why the literal is wrong. + +### Line 134 — DRay.DoOff crashes on ScriptParams links whose data is empty or non-string (new finding) + +- **Anchor:** `local data = split(::LinkTools.LinkGetData(link,null),"+")` +- **Severity:** P2 +- **Failure scenario:** `split("", "+")` returns an empty array (Squirrel split drops empty + tokens), so the `data[0] == "DRay"` test at line 135 throws index out of range for any + `ScriptParams` link with empty data; integer link data makes `split()` throw directly. Any + mission object that both runs DRay and carries an unrelated ScriptParams link crashes on + TurnOff. +- **Verification:** Same mechanism as the DoOn finding above, but here the guard exists and is + simply reached too late — the `split`/`data[0]` evaluation precedes it. Also note the field + argument inconsistency: DoOn reads link data with `""`, DoOff with `null` (line 51 vs 134). + +### Line 172 — DArmAttachment creates a new attached dummy on every InvSelect and never destroys any of them (new finding) + +- **Anchor:** `SetOneShotTimer("Equip",0.5) // Need a little delay here as the arm object is not instantly created at InvSelect.` +- **Severity:** P2 +- **Failure scenario:** Every `InvSelect` of the carrying object schedules "Equip", and the timer + handler (lines 178–225) unconditionally `BeginCreate`s a new dummy and attaches it to `PlyrArm`. + There is no DoOff, no InvDeSelect handler, and no `Object.Destroy` anywhere in the class — + select/deselect cycles accumulate one world object per cycle, all attached to the arm + simultaneously (visibly stacked models for UseObject=1 modes). +- **Verification:** Grep of the class body confirms no destroy/cleanup path and no record of the + created object (the local `sfxdummy` is dropped at end of scope). The file header's own note + ("this script is not 100% finished") corroborates. Editor-only verification in DromEd would show + stacking after two selects. + +### Line 339 — DObjectPanTo's timer re-arm stores the new timer under a garbage data key, so DoOff kills a stale handle and the orphan timer chain can restart the whole pan (tracked: T-35) + +- **Anchor:** `SetData(SetOneShotTimer("DFaceUpdate", message().data, message().data))` +- **Severity:** P2 +- **Failure scenario:** T-35 records the one-argument `SetData` mistake. The untracked + consequence chain: `"Active"` keeps holding the **first** timer handle, so + `KillTimer(ClearData("Active"))` in DoOff (line 387) kills an already-fired timer while the + live chain keeps running; on its next tick `FrameUpdate()` sees `target == null` (cleared by + DoOff, line 390) and calls `DoOn(userparams())` (line 329), which re-reads the Design Note and + restarts the pan that was just finished — an AutoOff pan can loop forever. +- **Verification:** Traced DoOff → member nulling (389–392) → orphan timer fires → OnTimer 337 → + FrameUpdate 328 `if (!target) return DoOn(userparams())`. DoOn then finds `IsDataSet("Active")` + false (DoOff cleared it) and arms a fresh timer at line 375 — the loop is closed. This goes + beyond T-35's "timer handle passed as data name" phrasing; the fix must both name the data slot + and kill the *current* timer on DoOff. + +### Line 338 — DObjectPanTo's float-interval mode goes permanently dead after a save/load (new finding) + +- **Anchor:** `if (FrameUpdate() && IsDataSet("Active")){ // false on reload->!target->DoOn() was called and new timer started there.` +- **Severity:** P2 +- **Failure scenario:** Timers persist across save/load but script members do not. Post-load the + pending "DFaceUpdate" timer fires with `target == null`, so `FrameUpdate()` calls `DoOn()`. The + comment claims DoOn starts a new timer, but DoOn only arms one when `!IsDataSet("Active")` + (line 366) — and `"Active"` **is** set (SetData persists). DoOn therefore returns null, the + `&&` short-circuits, no timer is re-armed, and the pan freezes one frame after every reload. + (Per-frame integer mode is unaffected: OnBeginScript line 345–352 re-registers it.) +- **Verification:** Confirmed the three premises independently: SetData persists across + reconstruction (CLAUDE.md / engine reference), members reset to class defaults (`target = null`, + line 272), and DoOn's arming is gated on `!IsDataSet("Active")` at line 366. The code comment at + line 338 documents the intended behavior, which the guard at 366 defeats. + +### Line 1435 — DDirector.GetPath calls ::DTestTrap.DumpTable under the documented Debug flag, crashing shipped missions (new finding) + +- **Anchor:** `::DTestTrap.DumpTable(Path)` +- **Severity:** P2 +- **Failure scenario:** Identical pattern to the wave-1-confirmed DMultiMessage finding + (Core:2106): `DPrint()` returns true whenever `[Script]Debug=1` is set, editor or not, and + `DTestTrap` exists only in the editor-only `DScript_ModdingTools.nut`. A shipped mission with + Debug set on a DDirector crashes at every path build. +- **Verification:** Wave 1's adversarial verification of the DRelayTrap unit already confirmed + this exact mechanism and explicitly listed SFX:1435 as a recurrence site; re-checked the line in + the current tree — unchanged. + +### Line 1442 — DDirector throws on ScriptParams waypoint links whose data is empty or non-numeric (both speed read and index scan) (new finding) + +- **Anchor:** `speed = LinkTools.LinkGetData(link, "").tofloat()` +- **Severity:** P2 +- **Failure scenario:** `"".tofloat()` and `"".tointeger()` throw in Squirrel (str2num failure). + SetNextTarget (line 1442) does this for the pan-speed link of the *current* waypoint, and + OnMessage's ScriptParams scan does `data.tointeger()` at line 1480 guarded only by + `data == null` (line 1478) — an empty string passes the guard and throws. A single + ScriptParams link with empty data anywhere on a waypoint or on the director kills the camera + ride mid-flight. +- **Verification:** Both sites read raw link data with no content validation; the `== null` guard + at 1478 does not cover `""` (LinkGetData returns an empty string for string-typed empty fields). + Squirrel's throwing tointeger/tofloat semantics verified against the language runtime (same + mechanism as the DRay finding above). + +### Line 1541 — DDirector's speed-0 "Jump" overruns the Path array when the jump target is the second-to-last waypoint (new finding) + +- **Anchor:** `Link.Create("TPathNext", self, Path[GetData("Active")+2])` +- **Severity:** P2 +- **Failure scenario:** OnMovingTerrainWaypoint stores a Jump when the next TPath link's Speed is + 0 (line 1518, target `Path[index + 1]`, legal). OnContinue then indexes + `Path[GetData("Active")+2]` (lines 1541 and again in the print at 1544). With + `Active == Path.len()-2`, that is `Path[Path.len()]` — index out of range, and the guard at + line 1540 (`IsDataSet("Active")`) does not help because "Active" is always set while riding. +- **Verification:** Traced the index bounds: OnMovingTerrainWaypoint guarantees only + `index ≤ len-2` when Jump is set; nothing re-checks before the `+2` access. The + `// case we jumped to last` comment shows the author expected `IsDataSet` to catch this, but + "Active" is an integer slot that is set for the entire ride (set at line 1598/1606). + +### Line 1644 — DDirector.DoOff dereferences Path (null) when a TurnOff arrives before any TurnOn (new finding) + +- **Anchor:** `local lastpause = LinkTools.LinkGetData(Link.GetOne("~TPath",Path.top()),"Pause (ms)")` +- **Severity:** P2 +- **Failure scenario:** `Path` is only populated by GetPath() inside DoOn/OnBeginScript-with- + Active. A stray TurnOff (DRelayTrap's DefOff default) on a director that never started skips the + CycleMode condition (falsy `&&` short-circuit before `Path.top()` at 1630) but then reaches + line 1644's unconditional `Path.top()` — a null dereference. The same DoOff path also indexes + `Path[0]`/`Path[1]` at 1660–1661. +- **Verification:** Member default `Path = null` (line 1428); the only assignments are in + GetPath(). DoOff has no `if (!Path)` guard anywhere. The class inherits DBaseTrap's standard + Off routing, so the message path is live in any mission that wires TurnOff to the director + before/independently of TurnOn. + +## Cleanup items (6) + +- **Line 51/134** (`::LinkTools.LinkGetData(link, "")` vs `::LinkTools.LinkGetData(link,null)`): + same read done with two different field arguments; pick one convention. +- **Line 72** (`if (time_max != d / vel_max){`): compares the *archetype's* cached value against + the new distance but writes to the *created* object — the "only change if distance changed" + guard is almost always true and re-Sets every update. +- **Line 1443/1477/1516/1521/1544/1651/1659** (`print("Speed is" + speed)` …): seven unconditional + dev prints in DDirector — the T-63 set; strip together with T-60. +- **Line 1490** (`DN["On" + mssg + "TOn"] <- messages // Storing this raw, will be analyzed during DRelayMessages.`): + OnMessage restores/deletes the `…Target` key after relaying (1495–1498) but leaves the `…TOn` + key in userparams() permanently — asymmetric cleanup of the same temporary-mutation trick. +- **Line 1567** (`if (DGetParam(GetClassName() + "Freelook", false))`): every other Freelook read + uses `_script + "Freelook"` (1596); this one uses GetClassName(), diverging under Copies. +- **Line 1608** (`else` after `if (true || …`): the whole PerFrame_Register else branch is + unreachable dead code until T-66 is resolved; either finish the non-fixed-time path or delete + it. + +## Incomplete items (4) + +- **Line 17** (`DRayAttach (not implemented)`): documented parameter, no implementation — + tracked T-73; the `attach` param is even read at line 27 and then ignored (dead TODO at 113). +- **Line 99** (`/*important TODO: Think about it`): the particle-count scaling maths is + self-cancelling — tracked T-51. +- **Line 158** (`= 2 (experimental and not really working)`): DArmAttachment modes 2/3 — + tracked T-74. +- **Line 1627** (`// Some last delay?`): DoOff carries an unresolved design question about + end-of-ride delay handling. + +## Suggestions (4) + +- **Guard both DRay link loops with the DoOff-style `data[0] == "DRay"` prefix check (and a + `data.len() == 3` check) before any indexing/conversion** — fixes three of the five DRay bugs in + one move; store the SFX comparison value as the *created* concrete id rather than the raw + parameter so the tointeger comparison becomes int-vs-int. +- **Give DRay.DoOff an `Object.Destroy(data[2].tointeger())` before the Link.Destroy** — restores + the intended Off semantics and stops the effect accumulation. +- **Track DArmAttachment's created dummy in SetData and destroy it on InvDeSelect (or before + creating the next one)** — one data slot removes the leak. +- **In DObjectPanTo, name the data slot (`SetData("Active", SetOneShotTimer(…))`) and make DoOff + kill the live timer *before* nulling members** — resolves T-35 together with both new + consequence bugs found here. diff --git a/docs/review/wave2/sfx-tweq-teleport.md b/docs/review/wave2/sfx-tweq-teleport.md new file mode 100644 index 0000000..40d5922 --- /dev/null +++ b/docs/review/wave2/sfx-tweq-teleport.md @@ -0,0 +1,63 @@ +# DTweqDevice + DDrunkPlayerTrap + Teleport family (DTPBase/DTeleportPlayerTrap/DTrapTeleporter/DPortal) + DModelByCount + +**File:** `DScript SFX.nut` · **Anchor:** line 1064 (`class DTweqDevice extends DBaseTrap`) + +**Status:** Contains bugs · Needs cleaning · Incomplete · Has suggestions + +## Overall assessment + +Of the seven classes in this unit, six already carry tracked P1 bugs in `docs/OPEN_TASKS.md` (T-43/T-44/T-45/T-46/T-18), all of which were re-verified against the current tree and still sit at the line numbers the register claims. This pass focused on what those rows do not cover: the two untracked classes (`DTweqDevice`, `DModelByCount`) turn out to have their own, independent defects — `DTweqDevice.DoOn` reuses and mutates a single joint-state variable across an entire `Joints` list instead of reading each joint's own state, and `DModelByCount` clamps its model index off-by-one in both places it's computed. Separately, `DDrunkPlayerTrap.DoOn` hardcodes its Design Note parameter names instead of using `_script`, silently breaking `Copies` support for the whole class in a way that is the same shape as tracked T-48 but at an untracked site, and `DPortal.OnEndScript` overrides the base class's cleanup hook without chaining to it, which is harmless unless a `DPortal` instance ever uses the framework's per-frame/per-mid-frame repeat mechanism. None of the six tracked-class findings below restate their T-nn rows; where a class had a tracked bug, this review looked for a *different* defect at a *different* line. + +## Confirmed bugs (4) + +### Line 1358 - DPortal.OnEndScript overrides the base cleanup hook without calling base.OnEndScript(), so ::DHandler never deregisters this instance if it ever used a per-frame/per-mid-frame repeat (new finding) + +- **Anchor:** `function OnEndScript(){` +- **Severity:** P2 +- **Failure scenario:** `DBaseTrap.OnEndScript()` (`DScript Core.nut:2044-2046`) calls `::DHandler.DeRegisterAll(this)`, which is how a script's `PerFrame_database`/`PerMidFrame_database` entry (registered by the inherited `OnBeginScript()` re-registration logic at `Core.nut:1652-1665` whenever `[Script]Repeat="NF"` or a frame-count `Delay="NF"` is used) gets cleaned up when the object/script goes away. `DPortal.OnEndScript()` (SFX.nut:1358-1360) only unsubscribes the Physics enter/exit message and returns — it never calls `base.OnEndScript()`. If a mission author ever puts `DPortalRepeat="NF"` (or an equivalent frame-delay/infinite-repeat parameter) on a `DPortal` object, `OnBeginScript()` will register it with `::DHandler` on load, but when that object is destroyed or its script ends, the registration is never removed, leaving `::DHandler` holding a stale per-frame/per-mid-frame callback into a defunct script instance. +- **Verification:** Confirmed. `DBaseTrap.OnEndScript` (`DScript Core.nut:2044-2046`) is the framework's only end-of-life cleanup and calls `::DHandler.DeRegisterAll(this)` (`Core:2459-2466`). `DPortal.OnEndScript` (SFX:1358-1360) is the sole `OnEndScript` in `DScript SFX.nut` and contains only the `Physics.UnsubscribeMsg` call — no `base.OnEndScript()` — even though its sibling `OnBeginScript` (SFX:1353-1356) does chain to base, and `DHub`'s override (`Core:2948-2952`) shows the expected `base.OnEndScript()` pattern. The precondition is real and generic: any frame-count `Delay="NF"` routes `DoOn` handling through `::DHandler.PerFrame_Register` (`Core:2012-2014`), stored under `_script+"InfRepeat"` and re-registered on load at `Core:1652-1665`, so a Design-Note-configured `DPortal` can be registered, and the override then suppresses the only automatic deregistration. Matches CLAUDE.md's "a specific handler suppresses `OnMessage()` … call base" gotcha. Anchor verified at SFX:1358. + +### Line 1120 - DTweqDevice.DoOn reads a single joint's on/off+reverse state once and then reuses/mutates that same value for every joint in the Joints list, so later joints inherit an earlier joint's reversal instead of their own (new finding) + +- **Anchor:** `current = current^TWEQ_AS_REVERSE // XOR reverses the reverse` +- **Severity:** P2 +- **Failure scenario:** `DoOn` (SFX.nut:1107-1130) computes `current` once per object from the *primary* joint's `AnimS` field (line 1116: `Property.Get(obj,"StTweqJoints","Joint"+primjoin+"AnimS")`), then loops over every entry in the `Joints` design-note list (default `"1,2,3,4,5,6"`) reusing that same local. Any joint prefixed with `-` XORs `current` in place (line 1120) before writing it back out (line 1123) — for the *next* joint in the same list, whatever XOR state the previous joint left behind is carried forward instead of being derived fresh. With `DTweqDeviceJoints="-1,-3"` on one object: joint 1 toggles `current`'s reverse bit and writes it; joint 3 then toggles it *again* (cancelling the first toggle) and writes the now-un-reversed value — so the second `-`-prefixed joint silently loses its own reversal, and every joint after the first is being written with a value that no longer reflects its own property state, only an accumulated toggle history of earlier joints in the list. +- **Verification:** Confirmed by direct reading of SFX:1113-1124. `current` is fetched once per target object from the primary joint only (SFX:1115-1116), then the inner `foreach (j in joints)` mutates that same local via `current = current^TWEQ_AS_REVERSE` for every `-`-prefixed entry (SFX:1120) and writes `current | TWEQ_AS_ONOFF` to each joint (SFX:1123). Two consequences follow mechanically: consecutive `-` entries cancel each other's XOR (the claimed `"-1,-3"` case), and an unprefixed joint that follows a `-` entry (e.g. `"−2,3"`) is written with the *reversed* state it never asked for. Contrast the constructor (SFX:1094-1101), which reads each joint's own `rate-low-high` field per iteration — per-joint derivation was clearly the intent. Anchor verified at SFX:1120. + +### Line 1173 - DDrunkPlayerTrap.DoOn reads all of its Design Note parameters via hardcoded "DDrunkPlayerTrap..." names instead of `_script + "..."`, so the class silently ignores `Copies` (new finding) + +- **Anchor:** `local l = DGetParam("DDrunkPlayerTrapInterval", 0.2, DN)` +- **Severity:** P2 +- **Failure scenario:** Every `DGetParam` call in `DoOn` (SFX.nut:1173, 1178-1183) is keyed on the literal string `"DDrunkPlayerTrap..."` rather than `_script + "..."`, contrary to the convention CLAUDE.md documents ("Always build parameter names as `_script + \"Foo\"`, never a hard-coded string") and contrary to `_script`'s role of carrying the `ClassName2…9` suffix when `Copies` is used. If a Design Note sets `Copies="2"` on a `DDrunkPlayerTrap` object intending copy 2 to use a different `Strength`/`Length`/`Mode` via `DDrunkPlayerTrap2Strength` etc., copy 2's `DoOn` invocation still reads the plain `"DDrunkPlayerTrapStrength"` key — i.e. copy 1's value — because `_script` is never consulted. This is the same shape of defect tracked as T-48 for `DStackToQVar`'s `"DStackToQVarVar"`, but it is a distinct, untracked site covering the whole parameter set of a different class. +- **Verification:** Confirmed. All seven `DGetParam` calls in `DoOn` (SFX:1173, 1178-1183) use the literal `"DDrunkPlayerTrap..."` prefix; nothing else in the class reads `_script`. The `Copies` mechanism is genuinely in play for this class: `DBaseFunction` re-invokes `DoOn` per copy via `RepeatForCopies(::callee(), DN)` (`Core:1834`), with `_script` mutated to `"DDrunkPlayerTrap2"` etc. (`Core:1626, 1633`), so copy 2+ reads copy 1's keys exactly as claimed. Checked against `docs/OPEN_TASKS.md`: T-46 covers this class's timer-payload ordering (different bug, SFX:1205/1212) and T-48 covers `DStackToQVar` — this site is untracked, so the "new" tag is correct. (Minor scope note: copies would additionally collide on the shared, un-prefixed `"DrunkTimer"` data slot at SFX:1169/1176, so fixing the parameter names alone won't make `Copies` fully usable — but the claimed lookup defect is real as stated.) Anchor verified at SFX:1173. + +### Line 1115 - DTweqDevice.DoOn reads CfgTweqJoints/StTweqJoints unconditionally for every object in Target, without the constructor's own TweqType-vs-Joints gate or Property.Possessed guard (new finding) + +- **Anchor:** `local primjoin = Property.Get(obj,"CfgTweqJoints","Primary Joint")` +- **Severity:** P3 +- **Failure scenario:** The constructor (SFX.nut:1082-1083) explicitly skips all joint handling with `if (control && control != eTweqType.kTweqTypeJoints) return`, and additionally only touches an object after checking `Property.Possessed(obj,"CfgTweqJoints")` (line 1087-1092, printing a warning and `continue`-ing otherwise). `DoOn` (line 1107-1130) has neither guard: it always runs the joint-toggle loop over every object in `Target` regardless of what `Control`/`TweqType` is set to, and calls `Property.Get(obj,"CfgTweqJoints","Primary Joint")` with no possession check. For a `DTweqDevice` instance configured with `Control` pointing at a non-Joints tweq type on objects that (consistent with the constructor's own gating logic) are not expected to carry `CfgTweqJoints`, `DoOn` still attempts to read that property and build `"Joint"+primjoin+"AnimS"` from whatever it gets back — a code path the constructor was specifically written to avoid taking for the same configuration. +- **Verification:** Confirmed by direct comparison of the two bodies. The constructor gates on `if (control && control != eTweqType.kTweqTypeJoints) return` (SFX:1082-1083) and per-object on `Property.Possessed(obj,"CfgTweqJoints")` with a warning + `continue` (SFX:1087-1092); `DoOn` (SFX:1107-1130) re-reads the same `Target`/`Joints`/`Control` parameters but applies neither guard before `Property.Get(obj,"CfgTweqJoints","Primary Joint")` at SFX:1115 and the unconditional per-joint `Property.Set(obj,"StTweqJoints","Joint"+j+"AnimS", current | TWEQ_AS_ONOFF)` at SFX:1123 ("is always On"). Concrete divergence: with `Control` set to a non-Joints tweq type on an object carrying both tweq kinds, the constructor deliberately leaves joints alone while `DoOn` still force-enables the joints tweq's AnimS on-flag; on objects without the property, `DoOn` builds field names from whatever `Property.Get` returns instead of warning and skipping. P3 severity is appropriate — misconfiguration-dependent, no damage on the documented Joints-control path. Anchor verified at SFX:1115. + +## Cleanup items (2) + +- **Line 1078** (`local joints = ::split(DGetParam(_script+"Joints","1,2,3,4,5,6",DN).tostring(),"[,]") // All, overkill but why not.`): The `Joints` design-note parsing (`DGetParam(...).tostring()` piped through `::split(..., "[,]")`) is duplicated verbatim between the constructor and `DoOn` (line 1110); a shared helper would keep the two from drifting if the format ever changes. +- **Line 1097** (`if (j[0] == '-')`): The constructor indexes the leading-`'-'` check and slice with literal `0`/`1` (`j[0]`, `j.slice(1)`), while `DoOn` uses the class's own named constants for the identical operation (`j[kGetFirstChar]`, `j.slice(kRemoveFirstChar)`, line 1119/1121) — an avoidable style inconsistency within the same class for the same logic. + +## Incomplete items (5) + +- **Line 1096** (`// rate-low-high(1) has no number. // TODO test for 0`): Author's own TODO — the `rate-low-high` field-name-suffix logic for joint `"1"`/`"-1"` (no numeric suffix) versus other joint numbers is flagged as untested for the zero/one-index edge case. +- **Line 1100** (`//TODO check this for rotating.`): Author's own TODO on the non-reversed `JointPos` branch of the constructor, questioning whether the `.y` field read is correct for rotating joints specifically. +- **Line 1137** (`Multiple Ons from the same source will reset the timer and will do a FadeIn again. Multiple sources DO stack.`): The class docstring documents that concurrent effects from different trigger sources should stack, but `DoOn` (line 1169-1184) unconditionally kills and replaces the single `"DrunkTimer"` on every `TurnOn` regardless of which source sent it — there is no per-source accumulation of `Strength`/effect, so "multiple sources DO stack" describes behavior the implementation does not have. +- **Line 1222** (`// low prio TODO: Make the movement swaying some function.`): Author's own low-priority TODO — the sway/push movement in `OnTimer` is acknowledged as not yet the intended wave-like motion described in the class docstring ("Tried to make a Wave Like movement but have jet so succeed"). +- **Line 1398** (`constructor() //If the object has already more stacks. TODO: Check Create statement and Constructor do the same thing twice.`): Author's own TODO questioning whether the constructor's model-setting logic and the `DoOn` "Create" branch (line 1416-1417) are redundant with each other. + +## Suggestions (4) + +- **Extract the `Joints` list-parsing expression (`::split(DGetParam(_script+"Joints", ...).tostring(), "[,]")`) into a shared private method on `DTweqDevice`, called from both the constructor and `DoOn`.** Removes the duplication flagged in Cleanup and gives future edits (e.g. changing the split delimiter or default list) a single place to land. +- **Compute each joint's own current `AnimS` value inside the `DoOn` joints loop (`Property.Get(obj,"StTweqJoints","Joint"+j+"AnimS")`) instead of reusing the primary joint's value fetched once outside the loop.** Directly addresses the state-leak bug above and matches what the per-joint `Property.Set` call on the same line already implies should be happening per-joint. +- **Route `DDrunkPlayerTrap`'s parameter lookups through `_script + "X"` instead of the literal class-name string, matching the convention already used correctly by `DTPBase`/`DTrapTeleporter`/`DPortal` in the same file (e.g. `_script + "Target"` at SFX.nut:1324).** Brings the class in line with CLAUDE.md's stated parameter convention and makes `Copies` usable for it. +- **Change both `DModelByCount` clamps from `if (stack > 5) stack = 5` to `if (stack > 4) stack = 4`, and consider factoring the clamp into a single shared expression used by both the constructor and `DoOn`** so the off-by-one can't recur independently in the two call sites the way it just did. + +## Candidate findings rejected on verification (1) + +- **Line 1401:** DModelByCount clamps the model index one slot too high in both the constructor and DoOn, so a stack of 6 requests a nonexistent "Model 5" field - _refuted:_ The claim's factual premise — that the `CfgTweqModels` property schema stops at `Model 4` — rests solely on the class docstring (SFX:1396), not on the engine. The Dark Engine's Tweq→Models property (`sTweqModelsCfg`) defines **six** model-name fields, `"Model 0"` through `"Model 5"`, so `Property.Get(obj,"CfgTweqModels","Model 5")` addresses a real field: with the existing `if (stack > 5) stack = 5` clamp, `StackCount == 6` maps to the valid last slot and `StackCount >= 7` clamps onto it, which is internally coherent. The identical clamp appears deliberately in both sites (SFX:1400-1402, 1412-1413) and unchanged in all three historical snapshots (`backup/DScript SFX.nut:949-963`, `SFX2.nut`, `SFXinventrbr.nut`), and nothing in the repo (searched `DOC/squirrel_script/` references and the docs PDF) supports a 5-slot schema. The only defect here is the docstring understating the model count by one ("limited to 5 different models" / "Model 0,1,2,3,4"), a documentation nit rather than the claimed wrong-field lookup; even in the counterfactual, `Property.Get` on an unknown field returns an empty value rather than "requesting a nonexistent field" destructively. From 5b55faed0e27cf156d0315526f945ec2e73510cd Mon Sep 17 00:00:00 2001 From: Daniel Sperber Date: Wed, 5 Aug 2026 16:42:08 +0000 Subject: [PATCH 7/9] docs: record the cleanup pass status and the Core.nut editing hazard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ALPHA_CLEANUP_PLAN.md: status header - which batches ran, in which commits, every deviation from the plan and why, and what was left for the bug-fix wave (Batch 3 and 5 are untouched and still valid as written). - CLAUDE.md: the encoding note was wrong. Byte histograms say only DScript Core.nut is still Latin-1 (45x §, 37x °); DScript File&Blob.nut and everything else already decode as UTF-8, though grep still needs -a on both. Added the concrete hazard: UTF-8 editors turn Core's high bytes into U+FFFD and break the case '§' label, so that one file needs a byte-safe latin-1 patch script. - CLAUDE.md: refreshed the two config rows the dedupe made stale, and reclassified DT2UndercoverWeapons.nut - not dead legacy but the opt-in companion of DImUndercover, matching the README and T-01. - OPEN_TASKS.md: T-93 re-checked and corrected the same way. --- CLAUDE.md | 30 +++++++++++++++++-------- docs/ALPHA_CLEANUP_PLAN.md | 46 ++++++++++++++++++++++++++++++++++++++ docs/OPEN_TASKS.md | 2 +- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7a0fc03..e4dcf3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,12 +23,15 @@ There is no build system, no package manager, no tests. The `.nut` files are dro | `DScript Overlays.nut` | `cDIngameLogOverlay` (in-game log), `cDHandlerFrameUpdater` (drives per-mid-frame updates), `cDWorldInvOverlay`. Picks Dark vs Shock overlay API | | `DScript_ModdingTools.nut` | Editor-only: `DSpy`, `DAutoTxtRepl`, `DDumpModels`, `DEditorTrap`, `DTestTrap` (`DumpTable`), `DPerformanceTest` | | `DSConfigDefault.nut` | **Read this first when changing behaviour.** All tunable consts, `eSeparator`, `eDQVarType` inputs, `MissionConstants`, and the `_dFROM` message-class patches | -| `DSConfigDefAutoTxt.nut` | Texture-replacement tables. Currently a **verbatim duplicate** of lines 117–231 of `DSConfigDefault.nut` | -| `DSConfigFix.nut` / `DSConfigMyFM.nut` | Per-mod / per-FM override stubs. Both declare `const kReplyMessage` | +| `DSConfigDefAutoTxt.nut` | Texture-replacement tables (`enum eDAutoTxtRepl`, `gDModTable`, `gDTexTable`). Sole owner since `2ca17b2` — the duplicate block in `DSConfigDefault.nut` was removed | +| `DSConfigFix.nut` / `DSConfigMyFM.nut` | Per-mod / per-FM override stubs. `const kReplyMessage` now lives only in the Fix layer; MyFM shows the override syntax as a comment | -### Legacy — do NOT edit, do NOT copy patterns from +### Old code — do NOT copy patterns from -`DT2UndercoverWeapons.nut` (defines `BlackJack`/`Sword`/`Arrow`; unrelated to V2, kept for reference). +`DT2UndercoverWeapons.nut` (defines `BlackJack`/`Sword`/`Arrow`). Written against the raw engine API, +not the V2 framework, so it is not a model for new work — but it is **not dead**: its own header makes +it the opt-in companion file for `DImUndercover` on Thief 2 ("INCLUDE it in your map if you do"), and +that is how the README's file-set table lists it. It never collided with a V2 class name. The v0.42a monolith `DScript.nut` and the v0.1b `DSEditorScripts.nut` — which used to redefine ~29 V2 class names and win the load-order race described below — were **deleted by the upstream merge @@ -181,8 +184,8 @@ Always build parameter names as `_script + "Foo"`, never a hard-coded string. ### grep needs `-a` on two files -`DScript Core.nut` and `DScript File&Blob.nut` are **ISO-8859-1**; grep classes them as binary and -returns *nothing* — no match, no warning, no error. +`DScript Core.nut` and `DScript File&Blob.nut` carry high bytes that make grep class them as binary, +so it returns *nothing* — no match, no warning, no error. ```bash grep -an "pattern" "DScript Core.nut" # -a is mandatory @@ -191,10 +194,19 @@ grep -arn "pattern" --include="*.nut" . # for repo-wide sweeps ### Encodings are mixed and load-bearing -Those two files are ANSI/Latin-1; the rest have drifted to UTF-8. `DScript Core.nut:1172` uses a -literal `§` as a `case` label in `DCheckString`, and `§`/`»` appear in fold markers throughout. +Only `DScript Core.nut` is still ANSI/Latin-1 (45× `§` = `0xA7`, 37× `°` = `0xB0`); everything else +decodes as UTF-8, including `DScript File&Blob.nut` despite what older notes said — but `grep` still +needs `-a` on both. `DScript Core.nut:1172` uses a literal `§` as a `case` label in `DCheckString`, +and `§`/`»` appear in fold markers throughout. **Never bulk re-save, re-encode, or normalize line endings** (files are a mix of LF and CRLF too). -Use `Edit` with exact byte-matched strings; avoid rewriting whole files. + +**The `Edit`/`Write` tools corrupt `DScript Core.nut`.** They decode as UTF-8, so every `0xA7`/`0xB0` +byte comes back as U+FFFD and the file is rewritten as UTF-8 — which breaks the `case '§'` label, +i.e. the whole parameter parser. Confirmed the hard way (2026-08-05). **Edit that one file with a +throwaway Python script instead**: read bytes, `.decode("latin-1")`, do exact string replacements +with a `count == 1` assertion each, `.encode("latin-1")`, and assert the `0xA7`/`0xB0` histogram is +unchanged before writing. Everything else takes `Edit` normally — verify with +`python3 -c "b=open(F,'rb').read(); b.decode('utf-8')"` afterwards. ### Filenames contain spaces and `&` diff --git a/docs/ALPHA_CLEANUP_PLAN.md b/docs/ALPHA_CLEANUP_PLAN.md index 34652cc..4041c0e 100644 --- a/docs/ALPHA_CLEANUP_PLAN.md +++ b/docs/ALPHA_CLEANUP_PLAN.md @@ -1,5 +1,51 @@ # Alpha Release — Cleanup Plan (no bug fixes) +> ## Execution status — 2026-08-05, branch `cleanup-alpha` +> +> **Done:** Batch 1 (all four sub-batches), Batch 2, Batch 4. +> **Deliberately not done:** Batch 3 (dead code) and Batch 5 (naming/style) — deferred on request, +> the pass was scoped to customer-facing behaviour. Every item in both batches is still valid as +> written below. +> +> | Commit | Contents | +> |---|---| +> | `29f3bb7` | Batch 1a — prints in `DScript Core.nut` | +> | `f284495` | Batch 1b–1d — prints in SFX, File&Blob, ModdingTools | +> | `2ca17b2` | Batch 2 — config dedupe, `.gitignore` | +> | `0d5e540` | Batch 4 — `KNOWN_ISSUES.md`, README, stale comments | +> +> **Deviations from the plan as written, and why:** +> - Three print sites the plan did not list were included, same defect class: `DTrapSetQVar`'s two +> `kDoPrint` `DPrint`s (`Core`) and `DPersistentSaveTrap`'s one (`File&Blob`) defaulted to mode +> `kMonolog|kUI`, i.e. an on-screen `DarkUI.TextMessage` in the shipped game. All three are now +> `Debug`-gated. A leftover top-level scratch snippet at the end of `DScript_ModdingTools.nut` that +> printed on every compile was removed too. +> - Where a listed print was the entire body of a loop, an `if`, or a handler override, the +> scaffolding had to go with it (`Core` `gSHARED_SET` foreach, the `DTrigger` compare, the +> `OnBeginScript` override). Where the structure carries meaning it was kept and commented: +> `FindFileInPath`'s two branches (T-67's bug half is untouched) and +> `DTrigQVar.OnDarkGameModeChange`, whose empty handler exists to stop the message reaching +> `OnMessage`. +> - `DScript_ModdingTools.nut` was swept but its remaining prints were kept: `DDumpModels` progress, +> `DumpTable`, `DImportObj` errors and `DPerformanceTest` results **are** those tools' output, and +> the file is editor-only. +> - `DT2UndercoverWeapons.nut` was **not** moved to `legacy/`. Its own header makes it the opt-in +> companion for `DImUndercover`, not legacy; it is documented in the README file-set table instead +> (the plan allowed either). +> - `DScript.SetQVar`'s ungated INFO print is commented out rather than deleted — the string was +> built on every QVar write, but it is the one useful hook for debugging QVar storage. +> - `DScript Overlays.nut` was not touched at all: it has zero `print()` calls, and its only entries +> in this plan are Batch 3 items. +> +> **Editing hazard found during the pass:** the agent `Edit`/`Write` tools decode as UTF-8 and +> silently destroyed all 82 Latin-1 high bytes in `DScript Core.nut` (`§`, `°` → U+FFFD), which would +> have broken the `case '§'` label in `DCheckString`. Reverted and re-applied via a byte-safe Python +> patch. See the encodings section of `CLAUDE.md` for the procedure. +> +> **Nothing here has been run.** Bracket balance per file was verified byte-identical to the +> pre-pass baseline and encodings/line endings are unchanged, but the acceptance test below still +> needs a DromEd `script_reload`. + **Goal:** get the V2 branch into an alpha-releasable state by removing development leftovers — above all user-visible log spam — **without changing any gameplay behavior**. Bug fixes are explicitly out of scope; they stay tracked in `docs/OPEN_TASKS.md` and the wave reports diff --git a/docs/OPEN_TASKS.md b/docs/OPEN_TASKS.md index 86f99d1..33b07d4 100644 --- a/docs/OPEN_TASKS.md +++ b/docs/OPEN_TASKS.md @@ -185,7 +185,7 @@ Author's own markers, worth knowing before designing anything nearby. | T-90 | `DScript Core.nut:926, 629` | `_GetInstance()` / `_tempstore._get` walk the call stack with **hard-coded depths (4, 5, 7)**. Any added or removed call frame silently breaks variable resolution inside `_` expressions. No test can catch this | ☐ | | T-91 | framework-wide | `RepeatForCopies` mutates `_script` on the live instance and re-enters the caller; `DTrigger` also appends/slices `"T"`. Several sites juggle `_script` by hand and must restore it — a missed restore corrupts every later parameter lookup on that instance | ☐ | | T-92 | `DScript Core.nut:1619-1640` | `Copies` is limited to 2–9 by single-character arithmetic (`_script[-1]`, `+ '0'`) | ☐ | -| T-93 | repo-wide | Encodings have drifted: only `DScript Core.nut` and `DScript File&Blob.nut` are still ANSI/Latin-1, the rest are UTF-8, and `§` is a literal `case` label at `Core:1172`. Whether NewDark tolerates the UTF-8 files is unverified | ☐ | +| T-93 | repo-wide | Encodings have drifted. Re-checked 2026-08-05 by byte histogram: **only `DScript Core.nut`** is still ANSI/Latin-1 (45× `0xA7` `§`, 37× `0xB0` `°`); `DScript File&Blob.nut` and everything else already decode as UTF-8, so the older "two Latin-1 files" note was wrong. `§` is a literal `case` label at `Core:1172`, so that file cannot be converted without changing the label. Whether NewDark tolerates the UTF-8 files is still unverified. **Editing hazard:** ordinary UTF-8 editors/tools silently turn Core's high bytes into U+FFFD — patch that file byte-safely, see CLAUDE.md | ☐ | --- From 48537090a65085e4cd8c174253a1d11ebb73a085 Mon Sep 17 00:00:00 2001 From: Daniel Sperber Date: Wed, 5 Aug 2026 16:44:27 +0000 Subject: [PATCH 8/9] tools: add byte-safe patcher and post-edit checker, restore lost chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small scripts, written for this cleanup pass and worth keeping - there is no other automated check in this repo. - tools/latin1_patch.py - edits DScript Core.nut as latin-1 instead of UTF-8. Every replace asserts its pattern matches exactly once, and save() refuses to write if the high-byte census (45x §, 37x °) changed. The § in DCheckString's case label is load-bearing, and UTF-8-assuming editors silently turn it into U+FFFD. - tools/check_files.py - run after any .nut edit. Reports encoding, line endings, high-byte census and bracket balance against a git ref, and fails on U+FFFD or on a balance that drifted from the baseline. That second check is the cheap way to catch a deleted debug print that was the entire body of a loop or an if, which silently re-parents the next statement. Running it immediately found pre-existing damage in DScript File&Blob.nut: 6 U+FFFD, from whichever earlier pass converted that file to UTF-8. Restored from backup/DScript File&Blob.nut, which is still CP1252 - the fold-marker banner on line 6 (4x §) and the comment about normalising „“ quotes. Comments and a Notepad++ fold marker only, no code involved. CLAUDE.md documents both tools and the hazard. --- CLAUDE.md | 23 +++++-- DScript File&Blob.nut | 4 +- tools/check_files.py | 106 ++++++++++++++++++++++++++++++++ tools/latin1_patch.py | 140 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 7 deletions(-) create mode 100755 tools/check_files.py create mode 100755 tools/latin1_patch.py diff --git a/CLAUDE.md b/CLAUDE.md index e4dcf3f..d64e66d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,11 +202,24 @@ and `§`/`»` appear in fold markers throughout. **The `Edit`/`Write` tools corrupt `DScript Core.nut`.** They decode as UTF-8, so every `0xA7`/`0xB0` byte comes back as U+FFFD and the file is rewritten as UTF-8 — which breaks the `case '§'` label, -i.e. the whole parameter parser. Confirmed the hard way (2026-08-05). **Edit that one file with a -throwaway Python script instead**: read bytes, `.decode("latin-1")`, do exact string replacements -with a `count == 1` assertion each, `.encode("latin-1")`, and assert the `0xA7`/`0xB0` histogram is -unchanged before writing. Everything else takes `Edit` normally — verify with -`python3 -c "b=open(F,'rb').read(); b.decode('utf-8')"` afterwards. +i.e. the whole parameter parser. Confirmed the hard way (2026-08-05). This is not hypothetical: the +same thing already happened to `DScript File&Blob.nut` before this repo was audited — its fold-marker +banner and one comment lost their characters permanently (restored in `d3a41c2` from the `backup/` +copy, which is still CP1252). + +Use [`tools/latin1_patch.py`](tools/latin1_patch.py) for that file — it edits as latin-1, asserts each +pattern matches exactly once, and refuses to write if the high-byte census changes. + +### Check your edits: `tools/check_files.py` + +```bash +python3 tools/check_files.py --base HEAD~1 # run after ANY .nut edit +``` + +Flags the two failures that have actually happened here: encoding damage (U+FFFD, changed high-byte +census) and structural damage (bracket balance drifting from a git baseline — which is what deleting +a `print()` that was the sole body of a loop or `if` looks like). It is not a syntax check; only +`script_reload` in DromEd is. ### Filenames contain spaces and `&` diff --git a/DScript File&Blob.nut b/DScript File&Blob.nut index 73e01c3..518242c 100644 --- a/DScript File&Blob.nut +++ b/DScript File&Blob.nut @@ -3,7 +3,7 @@ #include DScript.nut // File & Blob Library is standalone. -## /-- � �File_&_Blob_Library� � --\ +## /-- § §File_&_Blob_Library§ § --\ // // This file contains tools to interact with files (read only) and blobs. // Ultimately enabling the extraction of data/parameters from files. @@ -512,7 +512,7 @@ class dCSV extends dblob } } switch (c){ - // this fixes the string like �� to be a " + // this fixes the string like „“ to be a " case 108 - 255: case 109 - 255: case 124 - 255: diff --git a/tools/check_files.py b/tools/check_files.py new file mode 100755 index 0000000..bd3908a --- /dev/null +++ b/tools/check_files.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Post-edit sanity check for the .nut files. Run it after ANY edit. + +Nothing in this repo can be compiled, linted or run outside DromEd, so this is +the only automated check available. It catches the two failure modes that have +actually happened here: + +1. **Encoding damage.** A UTF-8-assuming tool rewrote `DScript Core.nut` and + turned all 82 latin-1 high bytes into U+FFFD, which would have broken the + `case '§'` label in `DCheckString`. Reported as a changed high-byte census + or a U+FFFD count above zero. +2. **Structural damage.** Deleting a debug `print()` that was the entire body of + a loop or an `if` silently re-parents the next statement. Reported as a + changed bracket balance versus a git baseline. + +It does NOT check Squirrel syntax - a balanced-but-wrong edit still needs +`script_reload` in DromEd. + +Usage +----- + python3 tools/check_files.py # census of every .nut in the root + python3 tools/check_files.py --base HEAD~1 # also diff brackets against a git ref + python3 tools/check_files.py --base 40a9e0a "DScript Core.nut" +""" + +import argparse +import collections +import glob +import subprocess +import sys + +# Files whose high bytes are load-bearing: byte census must not drift. +EXPECTED_HIGH_BYTES = { + "DScript Core.nut": {0xA7: 45, 0xB0: 37}, +} + + +def brackets(data): + return ( + data.count(b"{") - data.count(b"}"), + data.count(b"(") - data.count(b")"), + data.count(b"[") - data.count(b"]"), + ) + + +def census(data): + return dict(collections.Counter(c for c in data if c > 127)) + + +def show(path, data, base=None): + problems = [] + try: + text = data.decode("utf-8") + enc = "utf-8" + if "�" in text: + problems.append("%d U+FFFD replacement char(s) - this file was decoded as the wrong encoding and rewritten" % text.count("�")) + except UnicodeDecodeError: + enc = "latin-1" + + high = census(data) + want = EXPECTED_HIGH_BYTES.get(path.split("/")[-1]) + if want is not None and high != want: + problems.append("high-byte census is %r, expected %r" % (high, want)) + + crlf, lf = data.count(b"\r\n"), data.count(b"\n") + endings = "LF" if not crlf else ("CRLF" if crlf == lf else "MIXED %d CRLF / %d LF" % (crlf, lf)) + + bal = brackets(data) + note = "" + if base: + try: + old = subprocess.run(["git", "show", "%s:%s" % (base, path)], capture_output=True, check=True).stdout + except subprocess.CalledProcessError: + note = " (not in %s)" % base + else: + oldbal = brackets(old) + if oldbal != bal: + problems.append("bracket balance changed vs %s: {}()[] %r -> %r - an edit probably removed or orphaned a block" % (base, oldbal, bal)) + else: + note = " brackets match %s" % base + + status = "FAIL" if problems else "ok" + print("%-28s %-8s %-5s high=%-18s %s%s" % (path, enc, endings, high or "-", status, note)) + for p in problems: + print(" !! %s" % p) + return not problems + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("files", nargs="*", help="default: every .nut in the repo root") + ap.add_argument("--base", help="git ref to compare bracket balance against, e.g. HEAD~1") + args = ap.parse_args() + + files = args.files or sorted(glob.glob("*.nut")) + ok = True + for path in files: + ok &= show(path, open(path, "rb").read(), args.base) + if not ok: + print("\nAt least one file failed. Do not commit; restore from git and redo the edit.") + print("For DScript Core.nut use tools/latin1_patch.py, not a UTF-8 editor.") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/latin1_patch.py b/tools/latin1_patch.py new file mode 100755 index 0000000..6764ec1 --- /dev/null +++ b/tools/latin1_patch.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Byte-safe editor for the repo's Latin-1 script file(s). + +Why this exists +--------------- +`DScript Core.nut` is ISO-8859-1 and contains 45x `\xa7` (§) and 37x `\xb0` (°). +`§` is a literal `case` label in `DCheckString` (Core.nut:1172), so those bytes +are load-bearing: mangle them and the whole Design Note parser breaks. + +Most editors - and every UTF-8-assuming agent tool - read the file as UTF-8, +replace each high byte with U+FFFD and write the file back out as UTF-8. That is +silent, total, and unrecoverable without git. This script edits the file as +latin-1 and refuses to write if the high-byte histogram changed. + +Use it for `DScript Core.nut`. Everything else in the repo is UTF-8 and can be +edited normally - run `tools/check_files.py` afterwards to confirm. + +Usage +----- +As a library (the normal case - write a throwaway script per change): + + import sys; sys.path.insert(0, "tools") + from latin1_patch import Latin1File + + f = Latin1File("DScript Core.nut") + f.replace('print("yohoho")', '') # must match exactly once + f.replace_line('print(divide[0])', None) # delete the whole line + f.save() # verifies, then writes + +Every `replace*` asserts the pattern occurs exactly once unless you pass +`count=`, so a typo fails loudly instead of editing the wrong place. + +From the shell, to check a file without changing it: + + python3 tools/check_files.py "DScript Core.nut" +""" + +import collections +import sys + +# The known-good high-byte census of DScript Core.nut. Any other latin-1 file +# gets its histogram snapshotted at load time instead. +KNOWN = { + "DScript Core.nut": {0xA7: 45, 0xB0: 37}, +} + + +def histogram(data): + return dict(collections.Counter(c for c in data if c > 127)) + + +class Latin1File: + def __init__(self, path, expect=None): + self.path = path + self.raw = open(path, "rb").read() + self.expect = expect if expect is not None else KNOWN.get(path.split("/")[-1], histogram(self.raw)) + got = histogram(self.raw) + if got != self.expect: + raise AssertionError("%s: high bytes are %r, expected %r - the file may already be damaged" % (path, got, self.expect)) + if b"\r" in self.raw: + raise AssertionError("%s: has CR bytes; this script assumes LF-only" % path) + self.text = self.raw.decode("latin-1") + + # -- editing --------------------------------------------------------- + def replace(self, old, new, count=1): + """Replace `old` with `new`. Asserts `old` occurs exactly `count` times.""" + found = self.text.count(old) + if found != count: + raise AssertionError("%r occurs %d time(s), expected %d" % (old, found, count)) + self.text = self.text.replace(old, new) + return self + + def find_line(self, needle, count=1): + """Index of the single line containing `needle`.""" + lines = self.text.split("\n") + hits = [i for i, l in enumerate(lines) if needle in l] + if len(hits) != count: + raise AssertionError("%r is on %d line(s) %r, expected %d" % (needle, len(hits), hits[:10], count)) + return hits[0] + + def replace_line(self, needle, text): + """Replace the single line containing `needle`, keeping its indentation. + + `text=None` deletes the line entirely. Use this rather than `replace` + when you don't want to reproduce the file's tab indentation by hand. + """ + lines = self.text.split("\n") + i = self.find_line(needle) + if text is None: + del lines[i] + else: + line = lines[i] + indent = line[: len(line) - len(line.lstrip("\t "))] + lines[i] = indent + text + self.text = "\n".join(lines) + return self + + def delete_lines(self, needle, before=0, after=0, expect=None): + """Delete the line containing `needle` plus `before`/`after` neighbours. + + `expect` is an optional list of stripped line contents to assert against + the window before deleting - use it whenever the removal takes + scaffolding with it (a `foreach` whose only body was a print, say), so a + line-number drift cannot quietly eat the wrong code. + """ + lines = self.text.split("\n") + i = self.find_line(needle) + lo, hi = i - before, i + after + window = lines[lo : hi + 1] + if expect is not None: + got = [l.strip() for l in window] + if got != [e.strip() for e in expect]: + raise AssertionError("window is %r, expected %r" % (got, expect)) + del lines[lo : hi + 1] + self.text = "\n".join(lines) + return self + + # -- output ---------------------------------------------------------- + def encode(self): + try: + out = self.text.encode("latin-1") + except UnicodeEncodeError as e: + raise AssertionError("edit introduced a character that is not latin-1: %s.\nUse a plain ASCII replacement - do not paste typographic quotes or dashes." % e) + got = histogram(out) + if got != self.expect: + raise AssertionError("high bytes would change: %r -> %r. Refusing to write." % (self.expect, got)) + return out + + def save(self, path=None): + out = self.encode() + target = path or self.path + open(target, "wb").write(out) + print("%s: wrote %d bytes (was %d), %d lines (was %d)" % (target, len(out), len(self.raw), self.text.count("\n") + 1, self.raw.decode("latin-1").count("\n") + 1)) + return self + + +if __name__ == "__main__": + for p in sys.argv[1:] or list(KNOWN): + f = Latin1File(p) + print("%s: OK - %r, %d lines" % (p, histogram(f.raw), f.text.count("\n") + 1)) From 99fe73cfc481c3c72e2e4c8e32235f1288994a53 Mon Sep 17 00:00:00 2001 From: Daniel Sperber Date: Wed, 5 Aug 2026 16:44:41 +0000 Subject: [PATCH 9/9] docs: point the encoding note at the real restore commit --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d64e66d..8eee28a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,8 +204,8 @@ and `§`/`»` appear in fold markers throughout. byte comes back as U+FFFD and the file is rewritten as UTF-8 — which breaks the `case '§'` label, i.e. the whole parameter parser. Confirmed the hard way (2026-08-05). This is not hypothetical: the same thing already happened to `DScript File&Blob.nut` before this repo was audited — its fold-marker -banner and one comment lost their characters permanently (restored in `d3a41c2` from the `backup/` -copy, which is still CP1252). +banner and one comment lost their characters (restored in `4853709` from the `backup/` copy, which is +still CP1252 — that is the fallback if it happens again). Use [`tools/latin1_patch.py`](tools/latin1_patch.py) for that file — it edits as latin-1, asserts each pattern matches exactly once, and refuses to write if the high-byte census changes.