modwright 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +35 -0
- package/README.md +212 -0
- package/assets/claude/agents/mw-explore.md +46 -0
- package/assets/claude/agents/mw-fixture.md +33 -0
- package/assets/claude/agents/mw-rule-author.md +51 -0
- package/assets/claude/agents/mw-scaffold-author.md +48 -0
- package/assets/claude/skills/mw-author.md +59 -0
- package/assets/claude/skills/mw-new-item.md +51 -0
- package/assets/claude/skills/mw-new-spell.md +50 -0
- package/assets/claude/skills/mw-scaffold.md +59 -0
- package/assets/claude/skills/mw-ship.md +57 -0
- package/assets/claude/skills/mw-validate.md +51 -0
- package/assets/claude/skills/mw-verify.md +59 -0
- package/assets/nivalisnights/extract_resources.py +177 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Config.json +5 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/BootstrapClient.lua +55 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/BootstrapServer.lua +81 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Log.lua +96 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Probes.lua +1038 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/ProbesClient.lua +74 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Protocol.lua +985 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Watch.lua +175 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/meta.lsx +45 -0
- package/bridges/bg3-se/README.md +99 -0
- package/bridges/bg3-se/bridge.json +68 -0
- package/bridges/cp2077-cet/ModWrightBridge/init.lua +375 -0
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +1104 -0
- package/bridges/cp2077-cet/ModWrightBridge/protocol.lua +1306 -0
- package/bridges/cp2077-cet/README.md +165 -0
- package/bridges/cp2077-cet/bridge.json +53 -0
- package/dist/core/bridge/arm.js +175 -0
- package/dist/core/bridge/cet-console.js +26 -0
- package/dist/core/bridge/cet-log.js +362 -0
- package/dist/core/bridge/index.js +11 -0
- package/dist/core/bridge/locate.js +35 -0
- package/dist/core/bridge/paths.js +34 -0
- package/dist/core/bridge/pending.js +89 -0
- package/dist/core/bridge/poll.js +155 -0
- package/dist/core/bridge/stage.js +100 -0
- package/dist/core/bridge/status.js +97 -0
- package/dist/core/bridge/types.js +145 -0
- package/dist/core/bridge/validate.js +107 -0
- package/dist/core/build/index.js +6 -0
- package/dist/core/build/manifest.js +47 -0
- package/dist/core/build/paths.js +32 -0
- package/dist/core/build/run.js +342 -0
- package/dist/core/build/stage.js +265 -0
- package/dist/core/build/steps/bg3.js +129 -0
- package/dist/core/build/steps/core.js +195 -0
- package/dist/core/build/steps/cyberpunk.js +95 -0
- package/dist/core/build/steps/listing.js +35 -0
- package/dist/core/build/types.js +13 -0
- package/dist/core/build/zip.js +148 -0
- package/dist/core/compat/check.js +113 -0
- package/dist/core/compat/pe.js +220 -0
- package/dist/core/compat/types.js +1 -0
- package/dist/core/compat/version.js +70 -0
- package/dist/core/deploy/guards.js +292 -0
- package/dist/core/deploy/index.js +5 -0
- package/dist/core/deploy/plan.js +696 -0
- package/dist/core/deploy/reload-apply.js +361 -0
- package/dist/core/deploy/reload.js +358 -0
- package/dist/core/deploy/sources.js +169 -0
- package/dist/core/deploy/types.js +1 -0
- package/dist/core/epic.js +54 -0
- package/dist/core/fsutil.js +191 -0
- package/dist/core/home.js +70 -0
- package/dist/core/knowledge/facts.js +229 -0
- package/dist/core/knowledge/index.js +4 -0
- package/dist/core/knowledge/search.js +112 -0
- package/dist/core/knowledge/store.js +123 -0
- package/dist/core/knowledge/types.js +1 -0
- package/dist/core/ledger/check.js +42 -0
- package/dist/core/ledger/hooks.js +80 -0
- package/dist/core/ledger/index.js +7 -0
- package/dist/core/ledger/query.js +17 -0
- package/dist/core/ledger/record.js +84 -0
- package/dist/core/ledger/status.js +53 -0
- package/dist/core/ledger/store.js +98 -0
- package/dist/core/ledger/types.js +108 -0
- package/dist/core/logs/index.js +4 -0
- package/dist/core/logs/registry.js +50 -0
- package/dist/core/logs/scan.js +195 -0
- package/dist/core/logs/triage.js +160 -0
- package/dist/core/logs/types.js +1 -0
- package/dist/core/lsx.js +10 -0
- package/dist/core/lz4.js +66 -0
- package/dist/core/mods.js +229 -0
- package/dist/core/process.js +49 -0
- package/dist/core/project/build-steps.js +83 -0
- package/dist/core/project/describe.js +74 -0
- package/dist/core/project/index.js +5 -0
- package/dist/core/project/load.js +146 -0
- package/dist/core/project/schema.js +264 -0
- package/dist/core/project/types.js +3 -0
- package/dist/core/prompts.js +72 -0
- package/dist/core/registry.js +35 -0
- package/dist/core/safety/backup.js +206 -0
- package/dist/core/safety/execute.js +188 -0
- package/dist/core/safety/index.js +5 -0
- package/dist/core/safety/plan.js +122 -0
- package/dist/core/safety/restore.js +172 -0
- package/dist/core/safety/types.js +1 -0
- package/dist/core/scaffold/bootstrap.js +7 -0
- package/dist/core/scaffold/catalog.js +28 -0
- package/dist/core/scaffold/claude-assets.js +271 -0
- package/dist/core/scaffold/collisions.js +64 -0
- package/dist/core/scaffold/emit.js +356 -0
- package/dist/core/scaffold/engine.js +680 -0
- package/dist/core/scaffold/ids.js +78 -0
- package/dist/core/scaffold/index.js +10 -0
- package/dist/core/scaffold/jsonpath.js +121 -0
- package/dist/core/scaffold/load.js +81 -0
- package/dist/core/scaffold/render.js +85 -0
- package/dist/core/scaffold/sequence.js +396 -0
- package/dist/core/scaffold/types.js +309 -0
- package/dist/core/steam.js +63 -0
- package/dist/core/surfaceutil.js +36 -0
- package/dist/core/testplan/check.js +67 -0
- package/dist/core/testplan/index.js +5 -0
- package/dist/core/testplan/load.js +100 -0
- package/dist/core/testplan/registry.js +317 -0
- package/dist/core/testplan/run.js +389 -0
- package/dist/core/testplan/types.js +82 -0
- package/dist/core/text/converters/bg3.js +50 -0
- package/dist/core/text/converters/cyberpunk.js +36 -0
- package/dist/core/text/converters/index.js +261 -0
- package/dist/core/text/diff/cr2w.js +180 -0
- package/dist/core/text/diff/index.js +16 -0
- package/dist/core/text/diff/loca.js +43 -0
- package/dist/core/text/diff/lsx.js +126 -0
- package/dist/core/text/handles.js +86 -0
- package/dist/core/text/index.js +6 -0
- package/dist/core/text/json.js +46 -0
- package/dist/core/text/templates/cache.js +71 -0
- package/dist/core/text/templates/index.js +3 -0
- package/dist/core/text/templates/library.js +245 -0
- package/dist/core/text/templates/types.js +51 -0
- package/dist/core/text/toolchain.js +16 -0
- package/dist/core/text/types.js +1 -0
- package/dist/core/text/validate.js +149 -0
- package/dist/core/text/xml.js +58 -0
- package/dist/core/toolchain/datafetch.js +151 -0
- package/dist/core/toolchain/env.js +63 -0
- package/dist/core/toolchain/features.js +170 -0
- package/dist/core/toolchain/index.js +8 -0
- package/dist/core/toolchain/install.js +191 -0
- package/dist/core/toolchain/locate.js +275 -0
- package/dist/core/toolchain/remember.js +84 -0
- package/dist/core/toolchain/run.js +318 -0
- package/dist/core/toolchain/specs.js +351 -0
- package/dist/core/toolchain/types.js +1 -0
- package/dist/core/types.js +1 -0
- package/dist/core/userconfig.js +66 -0
- package/dist/core/validate/context.js +139 -0
- package/dist/core/validate/index.js +5 -0
- package/dist/core/validate/registry.js +58 -0
- package/dist/core/validate/run.js +131 -0
- package/dist/core/validate/suppressions.js +91 -0
- package/dist/core/validate/types.js +2 -0
- package/dist/core/yamledit.js +51 -0
- package/dist/index.js +389 -0
- package/dist/server.js +1914 -0
- package/dist/surfaces/baldursgate3/compat.js +164 -0
- package/dist/surfaces/baldursgate3/identity.js +110 -0
- package/dist/surfaces/baldursgate3/index/build.js +331 -0
- package/dist/surfaces/baldursgate3/index/index.js +17 -0
- package/dist/surfaces/baldursgate3/index/info.js +40 -0
- package/dist/surfaces/baldursgate3/index/parsers/loca.js +41 -0
- package/dist/surfaces/baldursgate3/index/parsers/lsx.js +137 -0
- package/dist/surfaces/baldursgate3/index/parsers/stats.js +45 -0
- package/dist/surfaces/baldursgate3/index/parsers/treasuretable.js +57 -0
- package/dist/surfaces/baldursgate3/index/parsers/xmlutil.js +142 -0
- package/dist/surfaces/baldursgate3/index/queries.js +327 -0
- package/dist/surfaces/baldursgate3/index/schema.js +62 -0
- package/dist/surfaces/baldursgate3/index.js +3 -0
- package/dist/surfaces/baldursgate3/logs.js +278 -0
- package/dist/surfaces/baldursgate3/modsettings.js +115 -0
- package/dist/surfaces/baldursgate3/pak.js +153 -0
- package/dist/surfaces/baldursgate3/settings.js +49 -0
- package/dist/surfaces/baldursgate3/surface.js +232 -0
- package/dist/surfaces/baldursgate3/validators/bridge.js +7 -0
- package/dist/surfaces/baldursgate3/validators/files.js +476 -0
- package/dist/surfaces/baldursgate3/validators/items.js +1001 -0
- package/dist/surfaces/baldursgate3/validators/progression.js +734 -0
- package/dist/surfaces/baldursgate3/validators/stats.js +1076 -0
- package/dist/surfaces/baldursgate3/validators/story.js +790 -0
- package/dist/surfaces/baldursgate3/validators/templates.js +7 -0
- package/dist/surfaces/cyberpunk2077/compat.js +223 -0
- package/dist/surfaces/cyberpunk2077/index/build.js +231 -0
- package/dist/surfaces/cyberpunk2077/index/index.js +17 -0
- package/dist/surfaces/cyberpunk2077/index/info.js +51 -0
- package/dist/surfaces/cyberpunk2077/index/model.js +52 -0
- package/dist/surfaces/cyberpunk2077/index/parsers/tweak.js +483 -0
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +183 -0
- package/dist/surfaces/cyberpunk2077/index/queries.js +504 -0
- package/dist/surfaces/cyberpunk2077/index/resource.js +135 -0
- package/dist/surfaces/cyberpunk2077/index/schema.js +90 -0
- package/dist/surfaces/cyberpunk2077/index.js +3 -0
- package/dist/surfaces/cyberpunk2077/logs.js +742 -0
- package/dist/surfaces/cyberpunk2077/paths.js +12 -0
- package/dist/surfaces/cyberpunk2077/surface.js +218 -0
- package/dist/surfaces/cyberpunk2077/validators/bridge.js +7 -0
- package/dist/surfaces/cyberpunk2077/validators/cr2w.js +349 -0
- package/dist/surfaces/cyberpunk2077/validators/garments.js +1181 -0
- package/dist/surfaces/cyberpunk2077/validators/mesh.js +338 -0
- package/dist/surfaces/cyberpunk2077/validators/modsettings.js +120 -0
- package/dist/surfaces/cyberpunk2077/validators/packaging.js +463 -0
- package/dist/surfaces/cyberpunk2077/validators/red4ext.js +318 -0
- package/dist/surfaces/cyberpunk2077/validators/redscript.js +193 -0
- package/dist/surfaces/cyberpunk2077/validators/templates.js +7 -0
- package/dist/surfaces/cyberpunk2077/validators/tweaks.js +768 -0
- package/dist/surfaces/cyberpunk2077/validators/vehicles.js +531 -0
- package/dist/surfaces/cyberpunk2077/validators/xl.js +308 -0
- package/dist/surfaces/eldenring/binders.js +111 -0
- package/dist/surfaces/eldenring/compat.js +166 -0
- package/dist/surfaces/eldenring/dcx.js +49 -0
- package/dist/surfaces/eldenring/index.js +13 -0
- package/dist/surfaces/eldenring/loaders.js +132 -0
- package/dist/surfaces/eldenring/logs.js +67 -0
- package/dist/surfaces/eldenring/paramdef.js +256 -0
- package/dist/surfaces/eldenring/params.js +129 -0
- package/dist/surfaces/eldenring/surface.js +121 -0
- package/dist/surfaces/eldenring/tools.js +52 -0
- package/dist/surfaces/eldenring/validators/binders.js +193 -0
- package/dist/surfaces/eldenring/validators/packages.js +159 -0
- package/dist/surfaces/eldenring/validators/params.js +334 -0
- package/dist/surfaces/eldenring/validators/profiles.js +287 -0
- package/dist/surfaces/index.js +34 -0
- package/dist/surfaces/nivalisnights/compat.js +229 -0
- package/dist/surfaces/nivalisnights/index/build.js +310 -0
- package/dist/surfaces/nivalisnights/index/index.js +5 -0
- package/dist/surfaces/nivalisnights/index/queries.js +231 -0
- package/dist/surfaces/nivalisnights/index.js +4 -0
- package/dist/surfaces/nivalisnights/logs.js +473 -0
- package/dist/surfaces/nivalisnights/surface.js +195 -0
- package/dist/surfaces/skyrimse/archives.js +229 -0
- package/dist/surfaces/skyrimse/compat.js +163 -0
- package/dist/surfaces/skyrimse/index.js +12 -0
- package/dist/surfaces/skyrimse/loadorder.js +174 -0
- package/dist/surfaces/skyrimse/logs.js +241 -0
- package/dist/surfaces/skyrimse/plugins.js +149 -0
- package/dist/surfaces/skyrimse/records.js +94 -0
- package/dist/surfaces/skyrimse/surface.js +240 -0
- package/dist/surfaces/skyrimse/validators/archive-records.js +165 -0
- package/dist/surfaces/skyrimse/validators/archives.js +110 -0
- package/dist/surfaces/skyrimse/validators/plugins.js +374 -0
- package/dist/surfaces/skyrimse/validators/records.js +117 -0
- package/dist/surfaces/stardewvalley/compat.js +190 -0
- package/dist/surfaces/stardewvalley/config.js +42 -0
- package/dist/surfaces/stardewvalley/contentpatcher.js +203 -0
- package/dist/surfaces/stardewvalley/index/index.js +229 -0
- package/dist/surfaces/stardewvalley/index/wiki.js +190 -0
- package/dist/surfaces/stardewvalley/index.js +12 -0
- package/dist/surfaces/stardewvalley/logs.js +307 -0
- package/dist/surfaces/stardewvalley/mods.js +182 -0
- package/dist/surfaces/stardewvalley/surface.js +244 -0
- package/dist/surfaces/stardewvalley/validators/content.js +321 -0
- package/dist/surfaces/stardewvalley/validators/fields.js +147 -0
- package/dist/surfaces/stardewvalley/validators/loadorder.js +69 -0
- package/dist/surfaces/stardewvalley/validators/manifest.js +221 -0
- package/dist/surfaces/subnautica2/compat.js +138 -0
- package/dist/surfaces/subnautica2/index.js +5 -0
- package/dist/surfaces/subnautica2/logs.js +129 -0
- package/dist/surfaces/subnautica2/paks.js +173 -0
- package/dist/surfaces/subnautica2/surface.js +151 -0
- package/dist/surfaces/subnautica2/validators/packaging.js +298 -0
- package/dist/surfaces/valheim/compat.js +171 -0
- package/dist/surfaces/valheim/index/index.js +301 -0
- package/dist/surfaces/valheim/index/tables.js +103 -0
- package/dist/surfaces/valheim/index.js +11 -0
- package/dist/surfaces/valheim/logs.js +197 -0
- package/dist/surfaces/valheim/packaging.js +131 -0
- package/dist/surfaces/valheim/plugins.js +270 -0
- package/dist/surfaces/valheim/references.js +121 -0
- package/dist/surfaces/valheim/surface.js +223 -0
- package/dist/surfaces/valheim/validators/packaging.js +218 -0
- package/dist/surfaces/valheim/validators/plugins.js +287 -0
- package/dist/surfaces/valheim/validators/references.js +169 -0
- package/knowledge/baldursgate3/animations.gr2.yaml +166 -0
- package/knowledge/baldursgate3/audio.voice.yaml +182 -0
- package/knowledge/baldursgate3/audio.wwise.yaml +104 -0
- package/knowledge/baldursgate3/charactercreation.heads-hair.yaml +193 -0
- package/knowledge/baldursgate3/communitylibrary.shared-content.yaml +129 -0
- package/knowledge/baldursgate3/compatibilityframework.api.yaml +176 -0
- package/knowledge/baldursgate3/crash.causes.yaml +90 -0
- package/knowledge/baldursgate3/dialogue.dialogbank.yaml +120 -0
- package/knowledge/baldursgate3/dialogue.files.yaml +236 -0
- package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +386 -0
- package/knowledge/baldursgate3/dialogue.yaml +122 -0
- package/knowledge/baldursgate3/feats.classes.yaml +164 -0
- package/knowledge/baldursgate3/items.cloth-physics.yaml +167 -0
- package/knowledge/baldursgate3/items.roottemplates.yaml +320 -0
- package/knowledge/baldursgate3/items.visualbank.yaml +628 -0
- package/knowledge/baldursgate3/journal.quests.yaml +214 -0
- package/knowledge/baldursgate3/loadorder.modsettings.yaml +128 -0
- package/knowledge/baldursgate3/localization.handles.yaml +92 -0
- package/knowledge/baldursgate3/misc.encoding-and-practice.yaml +111 -0
- package/knowledge/baldursgate3/origins.companions.yaml +144 -0
- package/knowledge/baldursgate3/osiris.signatures.yaml +211 -0
- package/knowledge/baldursgate3/packaging.meta.yaml +155 -0
- package/knowledge/baldursgate3/packaging.paks.yaml +79 -0
- package/knowledge/baldursgate3/process.launch.yaml +34 -0
- package/knowledge/baldursgate3/progressions.yaml +92 -0
- package/knowledge/baldursgate3/publishing.modio-nexus.yaml +170 -0
- package/knowledge/baldursgate3/publishing.toolkit-modio.yaml +135 -0
- package/knowledge/baldursgate3/races.progression.yaml +228 -0
- package/knowledge/baldursgate3/scriptextender.console.yaml +257 -0
- package/knowledge/baldursgate3/scriptextender.yaml +252 -0
- package/knowledge/baldursgate3/stats.boosts.yaml +293 -0
- package/knowledge/baldursgate3/stats.equipment-kits.yaml +69 -0
- package/knowledge/baldursgate3/stats.interrupts.yaml +45 -0
- package/knowledge/baldursgate3/stats.itemcombos.yaml +65 -0
- package/knowledge/baldursgate3/stats.spells.yaml +293 -0
- package/knowledge/baldursgate3/stats.weapons.yaml +210 -0
- package/knowledge/baldursgate3/toolchain.divine.yaml +179 -0
- package/knowledge/baldursgate3/toolchain.lslib-formats.yaml +242 -0
- package/knowledge/baldursgate3/toolkit.levels.yaml +177 -0
- package/knowledge/baldursgate3/ui.icons-atlas.yaml +78 -0
- package/knowledge/baldursgate3/ui.icons.yaml +412 -0
- package/knowledge/baldursgate3/ui.mcm.yaml +161 -0
- package/knowledge/baldursgate3/vfx.materials.yaml +174 -0
- package/knowledge/baldursgate3/visuals.characterfix.yaml +73 -0
- package/knowledge/cyberpunk2077/animations.anims.yaml +118 -0
- package/knowledge/cyberpunk2077/anims.archivexl.yaml +163 -0
- package/knowledge/cyberpunk2077/anims.authoring.yaml +579 -0
- package/knowledge/cyberpunk2077/anims.encoder.yaml +156 -0
- package/knowledge/cyberpunk2077/anims.graph.yaml +629 -0
- package/knowledge/cyberpunk2077/anims.method.yaml +360 -0
- package/knowledge/cyberpunk2077/anims.pipeline.yaml +352 -0
- package/knowledge/cyberpunk2077/anims.roundtrip.yaml +222 -0
- package/knowledge/cyberpunk2077/anims.sets.yaml +176 -0
- package/knowledge/cyberpunk2077/archivexl.bodytypes.yaml +149 -0
- package/knowledge/cyberpunk2077/archivexl.dynamic-appearances.yaml +218 -0
- package/knowledge/cyberpunk2077/archivexl.manifest.yaml +214 -0
- package/knowledge/cyberpunk2077/audio.events.yaml +168 -0
- package/knowledge/cyberpunk2077/audio.sounds.yaml +164 -0
- package/knowledge/cyberpunk2077/audio.voicesets.yaml +146 -0
- package/knowledge/cyberpunk2077/cet.sandbox.yaml +219 -0
- package/knowledge/cyberpunk2077/clothing.garments.yaml +283 -0
- package/knowledge/cyberpunk2077/clothing.refits.yaml +215 -0
- package/knowledge/cyberpunk2077/codeware.overview.yaml +111 -0
- package/knowledge/cyberpunk2077/codeware.systems.yaml +192 -0
- package/knowledge/cyberpunk2077/drones.combat.yaml +416 -0
- package/knowledge/cyberpunk2077/drones.control.yaml +552 -0
- package/knowledge/cyberpunk2077/entities.appearance.yaml +166 -0
- package/knowledge/cyberpunk2077/equipmentex.slots.yaml +151 -0
- package/knowledge/cyberpunk2077/fx.effects.yaml +246 -0
- package/knowledge/cyberpunk2077/fx.lights.yaml +171 -0
- package/knowledge/cyberpunk2077/hair.modding.yaml +145 -0
- package/knowledge/cyberpunk2077/items.wiring.yaml +246 -0
- package/knowledge/cyberpunk2077/loadorder.archives.yaml +71 -0
- package/knowledge/cyberpunk2077/mesh.blender.yaml +209 -0
- package/knowledge/cyberpunk2077/nativedb.dump.yaml +98 -0
- package/knowledge/cyberpunk2077/npc.appearance.yaml +275 -0
- package/knowledge/cyberpunk2077/npc.behaviour.yaml +128 -0
- package/knowledge/cyberpunk2077/npc.locomotion.yaml +490 -0
- package/knowledge/cyberpunk2077/packaging.deploy.yaml +175 -0
- package/knowledge/cyberpunk2077/packaging.variants.yaml +176 -0
- package/knowledge/cyberpunk2077/player.control.yaml +918 -0
- package/knowledge/cyberpunk2077/process.assetbuild.yaml +154 -0
- package/knowledge/cyberpunk2077/process.lessons.yaml +276 -0
- package/knowledge/cyberpunk2077/quests.minor.yaml +46 -0
- package/knowledge/cyberpunk2077/quests.scenes.yaml +282 -0
- package/knowledge/cyberpunk2077/quickhacks.tiers.yaml +41 -0
- package/knowledge/cyberpunk2077/red4ext.api.yaml +645 -0
- package/knowledge/cyberpunk2077/red4ext.plugins.yaml +124 -0
- package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +245 -0
- package/knowledge/cyberpunk2077/redscript.compile-log.yaml +76 -0
- package/knowledge/cyberpunk2077/redscript.modules.yaml +118 -0
- package/knowledge/cyberpunk2077/toolchain.wolvenkit.yaml +163 -0
- package/knowledge/cyberpunk2077/tweakdb.loot-recipes.yaml +184 -0
- package/knowledge/cyberpunk2077/tweakdb.melee.yaml +352 -0
- package/knowledge/cyberpunk2077/tweakdb.weapons.yaml +325 -0
- package/knowledge/cyberpunk2077/tweakxl.hotreload.yaml +151 -0
- package/knowledge/cyberpunk2077/tweakxl.yaml-syntax.yaml +208 -0
- package/knowledge/cyberpunk2077/ui.icons.yaml +259 -0
- package/knowledge/cyberpunk2077/ui.inkatlas.yaml +81 -0
- package/knowledge/cyberpunk2077/ui.modsettings.yaml +204 -0
- package/knowledge/cyberpunk2077/vehicles.control.yaml +475 -0
- package/knowledge/cyberpunk2077/vehicles.dashboard-ui.yaml +203 -0
- package/knowledge/cyberpunk2077/vehicles.entities.yaml +195 -0
- package/knowledge/cyberpunk2077/vehicles.wiring.yaml +137 -0
- package/knowledge/cyberpunk2077/wolvenkit.cli.yaml +167 -0
- package/knowledge/cyberpunk2077/workspot.system.yaml +55 -0
- package/knowledge/cyberpunk2077/world.interactions.yaml +41 -0
- package/knowledge/cyberpunk2077/world.loot-container.yaml +167 -0
- package/knowledge/cyberpunk2077/world.sectors.yaml +233 -0
- package/knowledge/eldenring/formats.binders.yaml +237 -0
- package/knowledge/eldenring/launcher.me3.yaml +230 -0
- package/knowledge/eldenring/launcher.modengine2.yaml +119 -0
- package/knowledge/eldenring/params.paramdef.yaml +149 -0
- package/knowledge/eldenring/params.regulation.yaml +142 -0
- package/knowledge/eldenring/saves.online.yaml +118 -0
- package/knowledge/eldenring/toolchain.cli.yaml +256 -0
- package/knowledge/nivalisnights/modding.runtime.yaml +157 -0
- package/knowledge/skyrimse/archives.bsa.yaml +212 -0
- package/knowledge/skyrimse/install.detection.yaml +41 -0
- package/knowledge/skyrimse/loadorder.files.yaml +242 -0
- package/knowledge/skyrimse/plugins.esl.yaml +189 -0
- package/knowledge/skyrimse/plugins.format.yaml +224 -0
- package/knowledge/skyrimse/skse.runtime.yaml +283 -0
- package/knowledge/skyrimse/toolchain.xedit-spriggit.yaml +336 -0
- package/knowledge/stardewvalley/contentpatcher.format.yaml +338 -0
- package/knowledge/stardewvalley/distribution.channels.yaml +236 -0
- package/knowledge/stardewvalley/game.versions.yaml +202 -0
- package/knowledge/stardewvalley/install.layout.yaml +277 -0
- package/knowledge/stardewvalley/smapi.loader.yaml +284 -0
- package/knowledge/stardewvalley/smapi.logs.yaml +329 -0
- package/knowledge/stardewvalley/smapi.manifest.yaml +293 -0
- package/knowledge/stardewvalley/toolchain.build.yaml +261 -0
- package/knowledge/stardewvalley/wiki.dataformat.yaml +130 -0
- package/knowledge/subnautica2/game.build.yaml +331 -0
- package/knowledge/subnautica2/modding.state.yaml +210 -0
- package/knowledge/subnautica2/ue5.containers.yaml +72 -0
- package/knowledge/subnautica2/ue5.paks.yaml +203 -0
- package/knowledge/subnautica2/ue5.tooling.yaml +175 -0
- package/knowledge/subnautica2/ue5.ue4ss.yaml +203 -0
- package/knowledge/valheim/bepinex.loader.yaml +247 -0
- package/knowledge/valheim/bepinex.plugins.yaml +251 -0
- package/knowledge/valheim/game.versions.yaml +321 -0
- package/knowledge/valheim/install.layout.yaml +216 -0
- package/knowledge/valheim/jotunn.library.yaml +334 -0
- package/knowledge/valheim/thunderstore.packaging.yaml +217 -0
- package/knowledge/valheim/toolchain.build.yaml +111 -0
- package/package.json +29 -0
- package/scaffolds/baldursgate3/new-class-injection.yaml +148 -0
- package/scaffolds/baldursgate3/new-item.yaml +264 -0
- package/scaffolds/baldursgate3/new-mod.yaml +174 -0
- package/scaffolds/baldursgate3/new-passive.yaml +100 -0
- package/scaffolds/baldursgate3/new-project.yaml +118 -0
- package/scaffolds/baldursgate3/new-quest-stub.yaml +159 -0
- package/scaffolds/baldursgate3/new-spell.yaml +102 -0
- package/scaffolds/baldursgate3/new-status.yaml +119 -0
- package/scaffolds/baldursgate3/sequences/subclass-kit.yaml +50 -0
- package/scaffolds/cyberpunk2077/edit-tweak.yaml +70 -0
- package/scaffolds/cyberpunk2077/mesh-export-preset.yaml +170 -0
- package/scaffolds/cyberpunk2077/new-entity-patch.yaml +255 -0
- package/scaffolds/cyberpunk2077/new-garment-refit.yaml +279 -0
- package/scaffolds/cyberpunk2077/new-item-chain.yaml +548 -0
- package/scaffolds/cyberpunk2077/new-localization.yaml +130 -0
- package/scaffolds/cyberpunk2077/new-mod-settings.yaml +133 -0
- package/scaffolds/cyberpunk2077/new-player-replacer.yaml +1003 -0
- package/scaffolds/cyberpunk2077/new-project.yaml +157 -0
- package/scaffolds/cyberpunk2077/new-sector-node.yaml +156 -0
- package/scaffolds/cyberpunk2077/new-tweak.yaml +121 -0
- package/scaffolds/cyberpunk2077/new-vehicle-livery.yaml +148 -0
- package/scaffolds/cyberpunk2077/new-workspot-entity.yaml +177 -0
- package/scaffolds/cyberpunk2077/sequences/iconic-weapon.yaml +72 -0
- package/scaffolds/cyberpunk2077/sequences/quickhack-takeover.yaml +213 -0
- package/scaffolds/stardewvalley/new-cp-patch.yaml +106 -0
- package/scaffolds/stardewvalley/new-csharp-mod.yaml +195 -0
- package/scaffolds/stardewvalley/new-project.yaml +184 -0
- package/scaffolds/stardewvalley/sequences/content-pack.yaml +31 -0
- package/scripts/mw-tool.mjs +184 -0
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { walk } from "../../../core/fsutil.js";
|
|
3
|
+
import { parseLoca } from "../index/parsers/loca.js";
|
|
4
|
+
import { parseStats } from "../index/parsers/stats.js";
|
|
5
|
+
import { attr, child, children, collectProjectLsx, collectRootTemplates } from "./items.js";
|
|
6
|
+
function posix(p) {
|
|
7
|
+
return p.split(path.sep).join("/");
|
|
8
|
+
}
|
|
9
|
+
function lower(p) {
|
|
10
|
+
return posix(p).toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
const STATS_PATH_RE = /\/stats\/generated\/data\/[^/]+\.txt$/;
|
|
13
|
+
const isStatsFile = (p) => STATS_PATH_RE.test(p);
|
|
14
|
+
const isLocaXml = (p) => p.endsWith(".xml") && p.includes("/localization/");
|
|
15
|
+
function fileTarget(match) {
|
|
16
|
+
return (target) => target.kind === "file" && match(lower(target.path));
|
|
17
|
+
}
|
|
18
|
+
function projectTarget() {
|
|
19
|
+
return (target) => target.kind === "project";
|
|
20
|
+
}
|
|
21
|
+
function finding(validatorId, severity, file, message, evidence, extra) {
|
|
22
|
+
return { validatorId, severity, file, message, evidence, ...extra };
|
|
23
|
+
}
|
|
24
|
+
async function projectFiles(project, match) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const src of project.sources) {
|
|
27
|
+
if (!src.exists)
|
|
28
|
+
continue;
|
|
29
|
+
const { files } = await walk(src.absPath, { limit: 20000 });
|
|
30
|
+
for (const f of files) {
|
|
31
|
+
const abs = path.join(src.absPath, f);
|
|
32
|
+
if (match(lower(abs)))
|
|
33
|
+
out.push(abs);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function statMapKey(type, name) {
|
|
39
|
+
return `${type.toLowerCase()}::${name.toLowerCase()}`;
|
|
40
|
+
}
|
|
41
|
+
async function buildProjectStatMap(ctx) {
|
|
42
|
+
const files = await projectFiles(ctx.project, isStatsFile);
|
|
43
|
+
const map = new Map();
|
|
44
|
+
for (const f of files) {
|
|
45
|
+
try {
|
|
46
|
+
const text = await ctx.readText(f);
|
|
47
|
+
for (const entry of parseStats(text, f))
|
|
48
|
+
map.set(statMapKey(entry.type, entry.name), entry);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
function lookupStat(map, name, preferType) {
|
|
56
|
+
if (preferType) {
|
|
57
|
+
const preferred = map.get(statMapKey(preferType, name));
|
|
58
|
+
if (preferred)
|
|
59
|
+
return preferred;
|
|
60
|
+
}
|
|
61
|
+
const lowerName = name.toLowerCase();
|
|
62
|
+
let found;
|
|
63
|
+
for (const entry of map.values()) {
|
|
64
|
+
if (entry.name.toLowerCase() === lowerName)
|
|
65
|
+
found = entry;
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
async function vanillaStat(ctx, name) {
|
|
70
|
+
const res = await ctx.lookup("stat", { name, limit: 1 });
|
|
71
|
+
const row = res.rows[0];
|
|
72
|
+
return row;
|
|
73
|
+
}
|
|
74
|
+
async function resolveInChain(ctx, projectMap, startUsing, field, preferType, cap = 10) {
|
|
75
|
+
let current = startUsing;
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
let depth = 0;
|
|
78
|
+
while (current && depth < cap && !seen.has(current)) {
|
|
79
|
+
seen.add(current);
|
|
80
|
+
const proj = lookupStat(projectMap, current, preferType);
|
|
81
|
+
if (proj) {
|
|
82
|
+
if (proj.fields[field])
|
|
83
|
+
return true;
|
|
84
|
+
current = proj.using;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const row = await vanillaStat(ctx, current);
|
|
88
|
+
if (!row)
|
|
89
|
+
return false;
|
|
90
|
+
if (row.fields[field])
|
|
91
|
+
return true;
|
|
92
|
+
current = row.using ?? undefined;
|
|
93
|
+
}
|
|
94
|
+
depth++;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
async function statExists(ctx, projectMap, name) {
|
|
99
|
+
if (lookupStat(projectMap, name))
|
|
100
|
+
return true;
|
|
101
|
+
const row = await vanillaStat(ctx, name);
|
|
102
|
+
return row !== undefined;
|
|
103
|
+
}
|
|
104
|
+
const EXPLOSION_CALL_RE = /\bCreateExplosion(?:AtPosition)?\s*\(([^)]*)\)/g;
|
|
105
|
+
async function projectExplosionCallArgs(ctx) {
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const src of ctx.project.sources) {
|
|
108
|
+
if (!src.exists)
|
|
109
|
+
continue;
|
|
110
|
+
if (src.kind !== "mods" && src.kind !== "scriptextender")
|
|
111
|
+
continue;
|
|
112
|
+
const { files } = await walk(src.absPath, { limit: 20000 });
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
if (!f.toLowerCase().endsWith(".lua"))
|
|
115
|
+
continue;
|
|
116
|
+
try {
|
|
117
|
+
const text = await ctx.readText(path.join(src.absPath, f));
|
|
118
|
+
EXPLOSION_CALL_RE.lastIndex = 0;
|
|
119
|
+
let m;
|
|
120
|
+
while ((m = EXPLOSION_CALL_RE.exec(text)))
|
|
121
|
+
out.push(m[1] ?? "");
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function isExplosionOnlyInvoked(explosionCallArgs, spellName) {
|
|
130
|
+
return explosionCallArgs.some((args) => args.includes(spellName));
|
|
131
|
+
}
|
|
132
|
+
const spellanimationPresent = {
|
|
133
|
+
id: "bg3.history.spellanimation-present",
|
|
134
|
+
game: "baldursgate3",
|
|
135
|
+
groups: ["history"],
|
|
136
|
+
modTypes: ["dialogue"],
|
|
137
|
+
summary: "Every SpellData entry has SpellAnimation, or a using parent that does — otherwise the spell 'casts' and never resolves.",
|
|
138
|
+
appliesTo: fileTarget(isStatsFile),
|
|
139
|
+
appliesToDescription: "every Stats/Generated/Data/*.txt file",
|
|
140
|
+
needs: { project: true, index: true },
|
|
141
|
+
cost: "edit",
|
|
142
|
+
severity: "block",
|
|
143
|
+
evidence: ["bg3.crash.spell-missing-spellanimation"],
|
|
144
|
+
async run(ctx) {
|
|
145
|
+
if (ctx.target.kind !== "file")
|
|
146
|
+
return [];
|
|
147
|
+
const text = await ctx.readText(ctx.target.path);
|
|
148
|
+
const entries = parseStats(text, ctx.target.path);
|
|
149
|
+
const spellEntries = entries.filter((e) => e.type === "SpellData");
|
|
150
|
+
if (spellEntries.length === 0)
|
|
151
|
+
return [];
|
|
152
|
+
const projectMap = await buildProjectStatMap(ctx);
|
|
153
|
+
let explosionCallArgs;
|
|
154
|
+
const findings = [];
|
|
155
|
+
for (const entry of spellEntries) {
|
|
156
|
+
if (entry.fields.SpellAnimation)
|
|
157
|
+
continue;
|
|
158
|
+
const inherited = await resolveInChain(ctx, projectMap, entry.using, "SpellAnimation", "SpellData");
|
|
159
|
+
if (inherited)
|
|
160
|
+
continue;
|
|
161
|
+
explosionCallArgs ??= await projectExplosionCallArgs(ctx);
|
|
162
|
+
if (isExplosionOnlyInvoked(explosionCallArgs, entry.name)) {
|
|
163
|
+
findings.push(finding(spellanimationPresent.id, "info", ctx.target.path, `SpellData '${entry.name}' has no SpellAnimation, but is exempted: it is invoked only via a project Osi.CreateExplosion(AtPosition)(...) Lua call, which never fires the CastTextEvent "Cast" keyframe a SpellAnimation would be for. Vanilla ships the same shape (e.g. Projectile_MAG_ChargedLightning_EnsnaringShock_Explosion) with no SpellAnimation.`, spellanimationPresent.evidence, { line: entry.line }));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
findings.push(finding(spellanimationPresent.id, "block", ctx.target.path, `SpellData '${entry.name}' has no SpellAnimation, and no 'using' parent supplies one. The CastTextEvent "Cast" keyframe never fires: the spell "casts" but never resolves, with no error logged.`, spellanimationPresent.evidence, { line: entry.line, fix: `Add data "SpellAnimation" "<value>" to '${entry.name}', or give it a using parent that has one.` }));
|
|
167
|
+
}
|
|
168
|
+
return findings;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const islinkedspellcontainerLinks = {
|
|
172
|
+
id: "bg3.history.islinkedspellcontainer-links",
|
|
173
|
+
game: "baldursgate3",
|
|
174
|
+
groups: ["history"],
|
|
175
|
+
modTypes: ["dialogue"],
|
|
176
|
+
summary: "SpellFlags carrying IsLinkedSpellContainer requires a non-empty ContainerSpells, or it is an instant null-deref crash.",
|
|
177
|
+
appliesTo: fileTarget(isStatsFile),
|
|
178
|
+
appliesToDescription: "every Stats/Generated/Data/*.txt file",
|
|
179
|
+
needs: { project: true },
|
|
180
|
+
cost: "edit",
|
|
181
|
+
severity: "block",
|
|
182
|
+
evidence: ["bg3.crash.islinkedspellcontainer-unlinked"],
|
|
183
|
+
async run(ctx) {
|
|
184
|
+
if (ctx.target.kind !== "file")
|
|
185
|
+
return [];
|
|
186
|
+
const text = await ctx.readText(ctx.target.path);
|
|
187
|
+
const entries = parseStats(text, ctx.target.path);
|
|
188
|
+
const findings = [];
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
const flags = (entry.fields.SpellFlags ?? "").split(";").map((s) => s.trim());
|
|
191
|
+
if (!flags.includes("IsLinkedSpellContainer"))
|
|
192
|
+
continue;
|
|
193
|
+
const container = (entry.fields.ContainerSpells ?? "").trim();
|
|
194
|
+
if (container.length > 0)
|
|
195
|
+
continue;
|
|
196
|
+
findings.push(finding(islinkedspellcontainerLinks.id, "block", ctx.target.path, `'${entry.name}' carries SpellFlags "IsLinkedSpellContainer" with no (or an empty) ContainerSpells. This is an instant null-deref crash. Use vanilla-proven flags (e.g. "HasSomaticComponent;HasVerbalComponent") instead, or populate ContainerSpells.`, islinkedspellcontainerLinks.evidence, { line: entry.line }));
|
|
197
|
+
}
|
|
198
|
+
return findings;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const boostcontextInitialEvent = {
|
|
202
|
+
id: "bg3.history.boostcontext-initial-event",
|
|
203
|
+
game: "baldursgate3",
|
|
204
|
+
groups: ["history"],
|
|
205
|
+
modTypes: ["dialogue"],
|
|
206
|
+
summary: "On PassiveData, a BoostContext omitting the initial-evaluation event (OnCreate) was observed never to apply at all, with no error — unless it exactly matches an attested vanilla combination. A StatusData boost was observed in game (2026-09-08) to apply on ApplyStatus regardless of BoostContext, so that assumption is confirmed for PassiveData only; verify per entry type.",
|
|
207
|
+
appliesTo: fileTarget(isStatsFile),
|
|
208
|
+
appliesToDescription: "every Stats/Generated/Data/*.txt file",
|
|
209
|
+
needs: { index: true },
|
|
210
|
+
cost: "edit",
|
|
211
|
+
severity: "block",
|
|
212
|
+
evidence: ["bg3.boosts.boostcontext-evaluation-events", "observed in game (2026-09-08): a StatusData boost applied on ApplyStatus regardless of BoostContext"],
|
|
213
|
+
async run(ctx) {
|
|
214
|
+
if (ctx.target.kind !== "file")
|
|
215
|
+
return [];
|
|
216
|
+
const text = await ctx.readText(ctx.target.path);
|
|
217
|
+
const entries = parseStats(text, ctx.target.path);
|
|
218
|
+
const findings = [];
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
const raw = entry.fields.BoostContext;
|
|
221
|
+
if (!raw)
|
|
222
|
+
continue;
|
|
223
|
+
const events = raw.split(";").map((s) => s.trim());
|
|
224
|
+
if (events.includes("OnCreate"))
|
|
225
|
+
continue;
|
|
226
|
+
const isStatusData = entry.type === "StatusData";
|
|
227
|
+
if (isStatusData) {
|
|
228
|
+
findings.push(finding(boostcontextInitialEvent.id, "info", ctx.target.path, `'${entry.name}' (StatusData) has BoostContext "${raw}" with no OnCreate. A Boosts field on a StatusData entry was observed in game (2026-09-08) to evaluate at ApplyStatus independent of BoostContext, so the "no OnCreate -> never applies" rule — confirmed only for PassiveData — does not hold the same way here. Recorded for visibility only; verify this entry's actual in-game behavior rather than relying on this check.`, boostcontextInitialEvent.evidence, { line: entry.line }));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const census = await ctx.lookup("field", { field: "BoostContext" });
|
|
232
|
+
const attested = census.rows.some((r) => String(r.value) === raw);
|
|
233
|
+
if (attested) {
|
|
234
|
+
findings.push(finding(boostcontextInitialEvent.id, "warn", ctx.target.path, `'${entry.name}' has BoostContext "${raw}" with no OnCreate, but this exact combination is attested in vanilla (e.g. a bloodied-style buff deliberately off until damaged). If this boost should be live the moment its source exists, add OnCreate. (This entry type is ${entry.type || "unrecorded"}; the "no OnCreate" behavior below is confirmed for PassiveData, not for StatusData — verify per entry type.)`, boostcontextInitialEvent.evidence, { line: entry.line }));
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
findings.push(finding(boostcontextInitialEvent.id, "block", ctx.target.path, `'${entry.name}' has BoostContext "${raw}" with no OnCreate and no vanilla precedent for this exact combination. On PassiveData, a BoostContext missing OnCreate was observed to never apply at all, with no error (~42 of ~46 vanilla usages include OnCreate; observed first in a test mod). It was observed not to hold for StatusData, so this warning assumes PassiveData semantics — this entry's type is ${entry.type || "unrecorded"}; verify per entry type. Add OnCreate, or omit BoostContext/BoostConditions entirely if the boost should always be live.`, boostcontextInitialEvent.evidence, { line: entry.line, fix: `Add OnCreate to BoostContext on '${entry.name}'.` }));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return findings;
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
const KNOWN_INVALID = [
|
|
244
|
+
{
|
|
245
|
+
field: "Conditions",
|
|
246
|
+
qualifier: "context.Source",
|
|
247
|
+
functor: /HasHPPercentageLessThan/,
|
|
248
|
+
types: ["PassiveData"],
|
|
249
|
+
evidence: ["bg3.boosts.context-source-invalid-hp-percent"],
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
field: "Conditions",
|
|
253
|
+
qualifier: "context.Observer",
|
|
254
|
+
types: ["PassiveData"],
|
|
255
|
+
evidence: ["bg3.boosts.context-observer-invalid-oncast-self", "bg3.stats.interrupt-observer-context"],
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
const KNOWN_VALID = [
|
|
259
|
+
{
|
|
260
|
+
field: "Conditions",
|
|
261
|
+
type: "InterruptData",
|
|
262
|
+
qualifiers: ["context.Source", "context.Observer", "context.Target"],
|
|
263
|
+
evidence: ["bg3.stats.interrupt-observer-context"],
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
const QUALIFIER_RE = /context\.(Source|Observer|Target)/g;
|
|
267
|
+
const HIGH_STAKES_FIELDS = new Set(["Conditions", "BoostConditions"]);
|
|
268
|
+
const contextQualifierValidity = {
|
|
269
|
+
id: "bg3.history.context-qualifier-validity",
|
|
270
|
+
game: "baldursgate3",
|
|
271
|
+
groups: ["history"],
|
|
272
|
+
modTypes: ["dialogue"],
|
|
273
|
+
summary: "A context.Source/Observer/Target qualifier is valid only for the specific field + functor context it appears in.",
|
|
274
|
+
appliesTo: fileTarget(isStatsFile),
|
|
275
|
+
appliesToDescription: "every Stats/Generated/Data/*.txt file",
|
|
276
|
+
needs: { index: true },
|
|
277
|
+
cost: "edit",
|
|
278
|
+
severity: "block",
|
|
279
|
+
evidence: [
|
|
280
|
+
"bg3.boosts.context-observer-invalid-oncast-self",
|
|
281
|
+
"bg3.boosts.context-source-invalid-hp-percent",
|
|
282
|
+
"bg3.boosts.context-qualifier-field-specific",
|
|
283
|
+
],
|
|
284
|
+
async run(ctx) {
|
|
285
|
+
if (ctx.target.kind !== "file")
|
|
286
|
+
return [];
|
|
287
|
+
const text = await ctx.readText(ctx.target.path);
|
|
288
|
+
const entries = parseStats(text, ctx.target.path);
|
|
289
|
+
const findings = [];
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
for (const [field, value] of Object.entries(entry.fields)) {
|
|
292
|
+
QUALIFIER_RE.lastIndex = 0;
|
|
293
|
+
let m;
|
|
294
|
+
const seenQualifiers = new Set();
|
|
295
|
+
while ((m = QUALIFIER_RE.exec(value))) {
|
|
296
|
+
const qualifier = `context.${m[1]}`;
|
|
297
|
+
if (seenQualifiers.has(qualifier))
|
|
298
|
+
continue;
|
|
299
|
+
seenQualifiers.add(qualifier);
|
|
300
|
+
if (KNOWN_VALID.some((k) => k.field === field && k.type === entry.type && k.qualifiers.includes(qualifier)))
|
|
301
|
+
continue;
|
|
302
|
+
const known = KNOWN_INVALID.find((k) => k.field === field &&
|
|
303
|
+
k.qualifier === qualifier &&
|
|
304
|
+
(!k.functor || k.functor.test(value)) &&
|
|
305
|
+
(!k.types || k.types.includes(entry.type)));
|
|
306
|
+
if (known) {
|
|
307
|
+
findings.push(finding(contextQualifierValidity.id, "block", ctx.target.path, `'${entry.name}'.${field} uses ${qualifier}, which is a known-invalid qualifier in this field context: the whole condition string evaluates false and every functor gated by it silently never fires.`, [...contextQualifierValidity.evidence, ...known.evidence], { line: entry.line }));
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const precedent = await ctx.lookup("precedent", { field, token: qualifier });
|
|
311
|
+
if (precedent.count === 0) {
|
|
312
|
+
const severity = HIGH_STAKES_FIELDS.has(field) ? "block" : "warn";
|
|
313
|
+
findings.push(finding(contextQualifierValidity.id, severity, ctx.target.path, `'${entry.name}'.${field} uses ${qualifier}, which has zero vanilla precedents in this field. A context qualifier proven valid in one field+functor context is not automatically valid in another; run a field-by-field vanilla census before relying on it.`, contextQualifierValidity.evidence, { line: entry.line }));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return findings;
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
const G_HANDLE_RE = /^h[0-9a-f]{8}g[0-9a-f]{4}g[0-9a-f]{4}g[0-9a-f]{4}g[0-9a-f]{12};\d+$/i;
|
|
322
|
+
const DASH_HANDLE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(;\d+)?$/i;
|
|
323
|
+
const G_FORM_FIELDS = ["DisplayName", "Description"];
|
|
324
|
+
const DASH_FORM_FIELDS = ["TooltipUpcastDescription", "ExtraDescription"];
|
|
325
|
+
const handleFormPerField = {
|
|
326
|
+
id: "bg3.history.handle-form-per-field",
|
|
327
|
+
game: "baldursgate3",
|
|
328
|
+
groups: ["history"],
|
|
329
|
+
modTypes: ["dialogue"],
|
|
330
|
+
summary: "DisplayName/Description use the h..g..;<version> handle form; TooltipUpcastDescription/ExtraDescription use the raw dashed UUID.",
|
|
331
|
+
appliesTo: fileTarget(isStatsFile),
|
|
332
|
+
appliesToDescription: "every Stats/Generated/Data/*.txt file",
|
|
333
|
+
needs: {},
|
|
334
|
+
cost: "edit",
|
|
335
|
+
severity: "block",
|
|
336
|
+
evidence: ["bg3.loca.display-name-format", "bg3.loca.handle-is-uuid-with-g-separators"],
|
|
337
|
+
async run(ctx) {
|
|
338
|
+
if (ctx.target.kind !== "file")
|
|
339
|
+
return [];
|
|
340
|
+
const text = await ctx.readText(ctx.target.path);
|
|
341
|
+
const entries = parseStats(text, ctx.target.path);
|
|
342
|
+
const findings = [];
|
|
343
|
+
for (const entry of entries) {
|
|
344
|
+
for (const field of G_FORM_FIELDS) {
|
|
345
|
+
const v = entry.fields[field];
|
|
346
|
+
if (v && DASH_HANDLE_RE.test(v)) {
|
|
347
|
+
findings.push(finding(handleFormPerField.id, "block", ctx.target.path, `'${entry.name}'.${field} = "${v}" is the raw dash-UUID form; ${field} must use the h..g..;<version> handle form instead.`, handleFormPerField.evidence, { line: entry.line, fix: `Use "h" + uuid.replace(/-/g, "g") + ";<version>" for ${field}.` }));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const field of DASH_FORM_FIELDS) {
|
|
351
|
+
const v = entry.fields[field];
|
|
352
|
+
if (v && G_HANDLE_RE.test(v)) {
|
|
353
|
+
findings.push(finding(handleFormPerField.id, "block", ctx.target.path, `'${entry.name}'.${field} = "${v}" is the h..g..;<version> handle form; ${field} must use the raw dashed UUID instead.`, handleFormPerField.evidence, { line: entry.line }));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return findings;
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
const HANDLE_FIELDS = [...G_FORM_FIELDS, ...DASH_FORM_FIELDS];
|
|
361
|
+
function toHandleKey(raw) {
|
|
362
|
+
const trimmed = raw.trim();
|
|
363
|
+
const g = /^h[0-9a-f]{8}g[0-9a-f]{4}g[0-9a-f]{4}g[0-9a-f]{4}g[0-9a-f]{12}/i.exec(trimmed);
|
|
364
|
+
if (g)
|
|
365
|
+
return g[0].toLowerCase();
|
|
366
|
+
const dash = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(trimmed);
|
|
367
|
+
if (dash)
|
|
368
|
+
return `h${dash[1].replace(/-/g, "g")}`.toLowerCase();
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
const handleDefinedInLoca = {
|
|
372
|
+
id: "bg3.history.handle-defined-in-loca",
|
|
373
|
+
game: "baldursgate3",
|
|
374
|
+
groups: ["history"],
|
|
375
|
+
modTypes: ["dialogue"],
|
|
376
|
+
summary: "Every handle referenced from stats, or from a TranslatedString attribute in a project .lsx, resolves to a <content contentuid> in this project's Localization XML or the vanilla index.",
|
|
377
|
+
appliesTo: projectTarget(),
|
|
378
|
+
appliesToDescription: "the whole project (every Stats/Generated/Data/*.txt and every TranslatedString attribute in a project .lsx, against every Localization/**/*.xml)",
|
|
379
|
+
needs: { project: true, index: true },
|
|
380
|
+
cost: "edit",
|
|
381
|
+
severity: "block",
|
|
382
|
+
evidence: ["bg3.loca.xml-location-and-format", "bg3.loca.handle-is-uuid-with-g-separators"],
|
|
383
|
+
async run(ctx) {
|
|
384
|
+
if (ctx.target.kind !== "project")
|
|
385
|
+
return [];
|
|
386
|
+
const statFiles = await projectFiles(ctx.project, isStatsFile);
|
|
387
|
+
const references = [];
|
|
388
|
+
for (const f of statFiles) {
|
|
389
|
+
let entries;
|
|
390
|
+
try {
|
|
391
|
+
entries = parseStats(await ctx.readText(f), f);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
for (const entry of entries) {
|
|
397
|
+
for (const field of HANDLE_FIELDS) {
|
|
398
|
+
const raw = entry.fields[field];
|
|
399
|
+
if (!raw)
|
|
400
|
+
continue;
|
|
401
|
+
const key = toHandleKey(raw);
|
|
402
|
+
if (key)
|
|
403
|
+
references.push({ key, file: f, line: entry.line, field });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const lsx = await collectProjectLsx(ctx);
|
|
408
|
+
for (const refs of lsx.byId.values()) {
|
|
409
|
+
for (const ref of refs) {
|
|
410
|
+
for (const [attrId, attribute] of Object.entries(ref.node.attributes)) {
|
|
411
|
+
if (attribute.type !== "TranslatedString" || !attribute.handle)
|
|
412
|
+
continue;
|
|
413
|
+
const key = toHandleKey(attribute.handle);
|
|
414
|
+
if (key)
|
|
415
|
+
references.push({ key, file: ref.file, field: `${ref.node.id}.${attrId}` });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (references.length === 0)
|
|
420
|
+
return [];
|
|
421
|
+
const locaFiles = await projectFiles(ctx.project, isLocaXml);
|
|
422
|
+
const defined = new Set();
|
|
423
|
+
for (const f of locaFiles) {
|
|
424
|
+
try {
|
|
425
|
+
for (const e of parseLoca(await ctx.readText(f), f))
|
|
426
|
+
defined.add(e.handle.replace(/;\d+$/, "").toLowerCase());
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const unresolved = [];
|
|
432
|
+
for (const ref of references) {
|
|
433
|
+
if (defined.has(ref.key))
|
|
434
|
+
continue;
|
|
435
|
+
const vanilla = await ctx.lookup("handle", { handle: ref.key });
|
|
436
|
+
if (vanilla.count > 0)
|
|
437
|
+
continue;
|
|
438
|
+
unresolved.push(ref);
|
|
439
|
+
}
|
|
440
|
+
if (unresolved.length === 0)
|
|
441
|
+
return [];
|
|
442
|
+
const ratio = unresolved.length / references.length;
|
|
443
|
+
if (ratio > 0.25) {
|
|
444
|
+
return [
|
|
445
|
+
finding(handleDefinedInLoca.id, "info", ctx.project.root, `${unresolved.length} of ${references.length} localization handles referenced from this project's stats and .lsx files do not resolve in its Localization XML or the vanilla index (${Math.round(ratio * 100)}%). ` +
|
|
446
|
+
`This ratio is high enough that ModWright suspects a partial project tree rather than that many real missing translations, so these are reported informationally rather than as blocking findings. Unresolved: ${unresolved
|
|
447
|
+
.slice(0, 10)
|
|
448
|
+
.map((r) => r.key)
|
|
449
|
+
.join(", ")}${unresolved.length > 10 ? ", ..." : ""}.`, handleDefinedInLoca.evidence),
|
|
450
|
+
];
|
|
451
|
+
}
|
|
452
|
+
return unresolved.map((ref) => finding(handleDefinedInLoca.id, "block", ref.file, `${ref.field} handle '${ref.key}' does not resolve to a <content contentuid> in this project's Localization XML or the vanilla index. The game shows "Not Found" for it.`, handleDefinedInLoca.evidence, ref.line !== undefined ? { line: ref.line } : {}));
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
const ENUMERATION_MARKER_RE = /^[ \t]*#[ \t]*modwright:enumeration[ \t]+(\S+)[ \t]*$/;
|
|
456
|
+
async function effectiveField(projectMap, name, field, cap = 10) {
|
|
457
|
+
let current = name;
|
|
458
|
+
const seen = new Set();
|
|
459
|
+
let depth = 0;
|
|
460
|
+
while (current && depth < cap && !seen.has(current)) {
|
|
461
|
+
seen.add(current);
|
|
462
|
+
const entry = lookupStat(projectMap, current, "SpellData");
|
|
463
|
+
if (!entry)
|
|
464
|
+
return undefined;
|
|
465
|
+
if (entry.fields[field] !== undefined)
|
|
466
|
+
return entry.fields[field];
|
|
467
|
+
current = entry.using;
|
|
468
|
+
depth++;
|
|
469
|
+
}
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
const spellidEnumerationDrift = {
|
|
473
|
+
id: "bg3.history.spellid-enumeration-drift",
|
|
474
|
+
game: "baldursgate3",
|
|
475
|
+
groups: ["history"],
|
|
476
|
+
modTypes: ["dialogue"],
|
|
477
|
+
summary: "A '# modwright:enumeration <root>'-marked hardcoded SpellId() list still matches the effective UseCosts/RitualCosts set for that spell family.",
|
|
478
|
+
appliesTo: fileTarget(isStatsFile),
|
|
479
|
+
appliesToDescription: "every Stats/Generated/Data/*.txt file carrying a '# modwright:enumeration <root-spell-id>' marker comment",
|
|
480
|
+
needs: { project: true },
|
|
481
|
+
cost: "edit",
|
|
482
|
+
severity: "warn",
|
|
483
|
+
evidence: ["bg3.spells.spellid-guard-check", "bg3.spells.spellpowerlevel-reads-1-under-free-cast"],
|
|
484
|
+
async run(ctx) {
|
|
485
|
+
if (ctx.target.kind !== "file")
|
|
486
|
+
return [];
|
|
487
|
+
const text = await ctx.readText(ctx.target.path);
|
|
488
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
489
|
+
const markers = [];
|
|
490
|
+
lines.forEach((raw, i) => {
|
|
491
|
+
const m = ENUMERATION_MARKER_RE.exec(raw);
|
|
492
|
+
if (m)
|
|
493
|
+
markers.push({ root: m[1], line: i + 1 });
|
|
494
|
+
});
|
|
495
|
+
if (markers.length === 0)
|
|
496
|
+
return [];
|
|
497
|
+
const projectMap = await buildProjectStatMap(ctx);
|
|
498
|
+
const findings = [];
|
|
499
|
+
for (const marker of markers) {
|
|
500
|
+
let valueLine;
|
|
501
|
+
let valueLineNo = marker.line;
|
|
502
|
+
for (let i = marker.line; i < lines.length; i++) {
|
|
503
|
+
const t = (lines[i] ?? "").trim();
|
|
504
|
+
if (t.length === 0)
|
|
505
|
+
continue;
|
|
506
|
+
valueLine = t;
|
|
507
|
+
valueLineNo = i + 1;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
if (!valueLine)
|
|
511
|
+
continue;
|
|
512
|
+
const dataMatch = /^data "([^"]*)" "(.*)"$/.exec(valueLine);
|
|
513
|
+
if (!dataMatch)
|
|
514
|
+
continue;
|
|
515
|
+
const enumerated = new Set();
|
|
516
|
+
const spellIdRe = /SpellId\('([^']+)'\)/g;
|
|
517
|
+
let sm;
|
|
518
|
+
while ((sm = spellIdRe.exec(dataMatch[2] ?? "")))
|
|
519
|
+
enumerated.add(sm[1]);
|
|
520
|
+
const family = [...projectMap.values()].filter((e) => e.type === "SpellData" && (e.name === marker.root || e.fields.RootSpellID === marker.root));
|
|
521
|
+
const required = new Map();
|
|
522
|
+
for (const spell of family) {
|
|
523
|
+
const useCosts = await effectiveField(projectMap, spell.name, "UseCosts");
|
|
524
|
+
const ritualCosts = await effectiveField(projectMap, spell.name, "RitualCosts");
|
|
525
|
+
if (ritualCosts)
|
|
526
|
+
continue;
|
|
527
|
+
const levelMatch = /SpellSlotsGroup:1:1:(\d+)/.exec(useCosts ?? "");
|
|
528
|
+
if (!levelMatch)
|
|
529
|
+
continue;
|
|
530
|
+
required.set(spell.name, Number(levelMatch[1]));
|
|
531
|
+
}
|
|
532
|
+
for (const [name, level] of required) {
|
|
533
|
+
if (!enumerated.has(name)) {
|
|
534
|
+
findings.push(finding(spellidEnumerationDrift.id, "warn", ctx.target.path, `The '# modwright:enumeration ${marker.root}' list is missing '${name}' (effective slot level ${level}). The enumeration has drifted from the current UseCosts.`, spellidEnumerationDrift.evidence, { line: valueLineNo }));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
for (const name of enumerated) {
|
|
538
|
+
const isFamilyMember = name === marker.root || name.startsWith(`${marker.root}_`);
|
|
539
|
+
if (isFamilyMember && !required.has(name)) {
|
|
540
|
+
findings.push(finding(spellidEnumerationDrift.id, "warn", ctx.target.path, `The '# modwright:enumeration ${marker.root}' list references '${name}', which is no longer a slot-costed non-ritual member of this spell family — stale entry.`, spellidEnumerationDrift.evidence, { line: valueLineNo }));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return findings;
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
const TARGET_KEYWORDS = new Set([
|
|
548
|
+
"SELF", "CASTER", "OWNER", "SWAP", "OBSERVER", "TARGET", "SOURCE",
|
|
549
|
+
"OBSERVER_OBSERVER", "OBSERVER_SOURCE", "OBSERVER_TARGET",
|
|
550
|
+
]);
|
|
551
|
+
const STATUS_CALL_RE = /(?:ApplyStatus|RemoveStatus)\(\s*([A-Za-z0-9_]+)\s*(?:,\s*([A-Za-z0-9_]+))?/g;
|
|
552
|
+
const referenceIntegrity = {
|
|
553
|
+
id: "bg3.history.reference-integrity",
|
|
554
|
+
game: "baldursgate3",
|
|
555
|
+
groups: ["history"],
|
|
556
|
+
modTypes: ["dialogue"],
|
|
557
|
+
summary: "The generalization of check_spelllists.py: using parents, ApplyStatus/RemoveStatus targets and RootSpellID resolve against the mod plus the vanilla index.",
|
|
558
|
+
appliesTo: fileTarget(isStatsFile),
|
|
559
|
+
appliesToDescription: "every Stats/Generated/Data/*.txt file",
|
|
560
|
+
needs: { project: true, index: true },
|
|
561
|
+
cost: "edit",
|
|
562
|
+
severity: "block",
|
|
563
|
+
evidence: ["bg3.practice.reference-checker-invaluable"],
|
|
564
|
+
async run(ctx) {
|
|
565
|
+
if (ctx.target.kind !== "file")
|
|
566
|
+
return [];
|
|
567
|
+
const text = await ctx.readText(ctx.target.path);
|
|
568
|
+
const entries = parseStats(text, ctx.target.path);
|
|
569
|
+
const projectMap = await buildProjectStatMap(ctx);
|
|
570
|
+
const findings = [];
|
|
571
|
+
for (const entry of entries) {
|
|
572
|
+
if (entry.using && !(await statExists(ctx, projectMap, entry.using))) {
|
|
573
|
+
findings.push(finding(referenceIntegrity.id, "block", ctx.target.path, `'${entry.name}' uses parent '${entry.using}', which does not exist in this project or the vanilla index.`, referenceIntegrity.evidence, { line: entry.line }));
|
|
574
|
+
}
|
|
575
|
+
const rootSpellId = entry.fields.RootSpellID;
|
|
576
|
+
if (rootSpellId && !(await statExists(ctx, projectMap, rootSpellId))) {
|
|
577
|
+
findings.push(finding(referenceIntegrity.id, "block", ctx.target.path, `'${entry.name}'.RootSpellID '${rootSpellId}' is not a defined SpellData in this project or vanilla.`, referenceIntegrity.evidence, { line: entry.line }));
|
|
578
|
+
}
|
|
579
|
+
for (const value of Object.values(entry.fields)) {
|
|
580
|
+
STATUS_CALL_RE.lastIndex = 0;
|
|
581
|
+
let m;
|
|
582
|
+
while ((m = STATUS_CALL_RE.exec(value))) {
|
|
583
|
+
let statusName = m[1];
|
|
584
|
+
if (TARGET_KEYWORDS.has(statusName) && m[2])
|
|
585
|
+
statusName = m[2];
|
|
586
|
+
if (statusName.startsWith("SG_") || TARGET_KEYWORDS.has(statusName))
|
|
587
|
+
continue;
|
|
588
|
+
if (!(await statExists(ctx, projectMap, statusName))) {
|
|
589
|
+
findings.push(finding(referenceIntegrity.id, "block", ctx.target.path, `'${entry.name}' applies/removes status '${statusName}', which is not defined anywhere in this project or the vanilla index.`, referenceIntegrity.evidence, { line: entry.line }));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return findings;
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
const EQUIPMENT_PATH_RE = /\/public\/[^/]+\/stats\/generated\/equipment\.txt$/;
|
|
598
|
+
const isEquipmentFile = (p) => EQUIPMENT_PATH_RE.test(p);
|
|
599
|
+
const KIT_ITEM_TYPES = ["Armor", "Weapon", "Object"];
|
|
600
|
+
const KIT_NEW_RE = /^new equipment "([^"]*)"$/;
|
|
601
|
+
const KIT_WEAPONSET_RE = /^add initialweaponset "([^"]*)"$/;
|
|
602
|
+
const KIT_GROUP_RE = /^add equipmentgroup$/;
|
|
603
|
+
const KIT_ENTRY_RE = /^add equipment entry "([^"]*)"$/;
|
|
604
|
+
export function parseEquipmentKits(text, file) {
|
|
605
|
+
const normalized = (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
606
|
+
const blocks = [];
|
|
607
|
+
let current;
|
|
608
|
+
let pendingGroup = false;
|
|
609
|
+
normalized.split("\n").forEach((raw, i) => {
|
|
610
|
+
const line = raw.trim();
|
|
611
|
+
if (line.length === 0 || line.startsWith("//"))
|
|
612
|
+
return;
|
|
613
|
+
const start = KIT_NEW_RE.exec(line);
|
|
614
|
+
if (start) {
|
|
615
|
+
current = { name: start[1] ?? "", line: i + 1, file, entries: [] };
|
|
616
|
+
blocks.push(current);
|
|
617
|
+
pendingGroup = false;
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (!current)
|
|
621
|
+
return;
|
|
622
|
+
const weaponSet = KIT_WEAPONSET_RE.exec(line);
|
|
623
|
+
if (weaponSet) {
|
|
624
|
+
current.initialWeaponSet = weaponSet[1] ?? "";
|
|
625
|
+
pendingGroup = false;
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (KIT_GROUP_RE.test(line)) {
|
|
629
|
+
pendingGroup = true;
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const entry = KIT_ENTRY_RE.exec(line);
|
|
633
|
+
if (entry) {
|
|
634
|
+
current.entries.push({ name: entry[1] ?? "", line: i + 1, grouped: pendingGroup });
|
|
635
|
+
}
|
|
636
|
+
pendingGroup = false;
|
|
637
|
+
});
|
|
638
|
+
return blocks;
|
|
639
|
+
}
|
|
640
|
+
async function resolveKitItem(ctx, projectMap, name) {
|
|
641
|
+
for (const type of KIT_ITEM_TYPES) {
|
|
642
|
+
const proj = projectMap.get(statMapKey(type, name));
|
|
643
|
+
if (proj)
|
|
644
|
+
return { type: proj.type, using: proj.using, fields: proj.fields, where: "project" };
|
|
645
|
+
}
|
|
646
|
+
const any = lookupStat(projectMap, name);
|
|
647
|
+
if (any)
|
|
648
|
+
return { type: any.type, using: any.using, fields: any.fields, where: "project" };
|
|
649
|
+
const row = await vanillaStat(ctx, name);
|
|
650
|
+
if (row)
|
|
651
|
+
return { type: row.type, using: row.using ?? undefined, fields: row.fields, where: "vanilla" };
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
async function effectiveFieldAcross(ctx, projectMap, name, field, preferType, cap = 10) {
|
|
655
|
+
let current = name;
|
|
656
|
+
const seen = new Set();
|
|
657
|
+
let depth = 0;
|
|
658
|
+
while (current && depth < cap && !seen.has(current)) {
|
|
659
|
+
seen.add(current);
|
|
660
|
+
const proj = lookupStat(projectMap, current, preferType);
|
|
661
|
+
if (proj) {
|
|
662
|
+
if (proj.fields[field] !== undefined)
|
|
663
|
+
return proj.fields[field];
|
|
664
|
+
current = proj.using;
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
const row = await vanillaStat(ctx, current);
|
|
668
|
+
if (!row)
|
|
669
|
+
return undefined;
|
|
670
|
+
if (row.fields[field] !== undefined)
|
|
671
|
+
return row.fields[field];
|
|
672
|
+
current = row.using ?? undefined;
|
|
673
|
+
}
|
|
674
|
+
depth++;
|
|
675
|
+
}
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
678
|
+
async function projectKitBlocks(ctx) {
|
|
679
|
+
const files = await projectFiles(ctx.project, isEquipmentFile);
|
|
680
|
+
const blocks = [];
|
|
681
|
+
for (const f of files) {
|
|
682
|
+
try {
|
|
683
|
+
blocks.push(...parseEquipmentKits(await ctx.readText(f), f));
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return blocks;
|
|
689
|
+
}
|
|
690
|
+
const equipmentKitEntriesResolve = {
|
|
691
|
+
id: "bg3.stats.equipment-kit-entries-resolve",
|
|
692
|
+
game: "baldursgate3",
|
|
693
|
+
groups: ["stats"],
|
|
694
|
+
modTypes: ["class", "armor", "weapon"],
|
|
695
|
+
summary: "Every `add equipment entry` in a Stats/Generated/Equipment.txt kit resolves to an Armor/Weapon/Object stat in the project or vanilla, and each kit block has the vanilla shape: `add initialweaponset` plus one `add equipmentgroup` before every entry.",
|
|
696
|
+
appliesTo: projectTarget(),
|
|
697
|
+
appliesToDescription: "the whole project (every Public/<Mod>/Stats/Generated/Equipment.txt)",
|
|
698
|
+
needs: { project: true, index: true },
|
|
699
|
+
cost: "edit",
|
|
700
|
+
severity: "block",
|
|
701
|
+
evidence: ["bg3.equipment.cc-kit-override-replaces-vanilla-entry", "bg3.practice.reference-checker-invaluable"],
|
|
702
|
+
async run(ctx) {
|
|
703
|
+
if (ctx.target.kind !== "project")
|
|
704
|
+
return [];
|
|
705
|
+
const blocks = await projectKitBlocks(ctx);
|
|
706
|
+
if (blocks.length === 0)
|
|
707
|
+
return [];
|
|
708
|
+
const projectMap = await buildProjectStatMap(ctx);
|
|
709
|
+
const findings = [];
|
|
710
|
+
for (const block of blocks) {
|
|
711
|
+
if (block.initialWeaponSet === undefined) {
|
|
712
|
+
findings.push(finding(equipmentKitEntriesResolve.id, "block", block.file, `Kit '${block.name}' has no 'add initialweaponset' line. Every vanilla EQP_CC_* kit opens with one (add initialweaponset "Melee") before its equipment groups.`, equipmentKitEntriesResolve.evidence, { line: block.line, fix: `Add 'add initialweaponset "Melee"' directly after 'new equipment "${block.name}"'.` }));
|
|
713
|
+
}
|
|
714
|
+
for (const entry of block.entries) {
|
|
715
|
+
if (!entry.grouped) {
|
|
716
|
+
findings.push(finding(equipmentKitEntriesResolve.id, "block", block.file, `Kit '${block.name}' entry "${entry.name}" is not preceded by 'add equipmentgroup'. The vanilla shape is a repeated pair: 'add equipmentgroup' then 'add equipment entry "<stat>"', one group per entry.`, equipmentKitEntriesResolve.evidence, { line: entry.line, fix: `Insert 'add equipmentgroup' on the line before 'add equipment entry "${entry.name}"'.` }));
|
|
717
|
+
}
|
|
718
|
+
const item = await resolveKitItem(ctx, projectMap, entry.name);
|
|
719
|
+
if (!item) {
|
|
720
|
+
findings.push(finding(equipmentKitEntriesResolve.id, "block", block.file, `Kit '${block.name}' entry "${entry.name}" does not resolve to any stat entry in this project or the vanilla index. The kit line hands out nothing (or fails to load) with no error logged.`, equipmentKitEntriesResolve.evidence, { line: entry.line }));
|
|
721
|
+
}
|
|
722
|
+
else if (!KIT_ITEM_TYPES.includes(item.type)) {
|
|
723
|
+
findings.push(finding(equipmentKitEntriesResolve.id, "block", block.file, `Kit '${block.name}' entry "${entry.name}" resolves to a ${item.type || "typeless"} entry (${item.where}), not an Armor, Weapon or Object stat. A kit can only hand out items.`, equipmentKitEntriesResolve.evidence, { line: entry.line }));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return findings;
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
const equipmentKitOneBodyArmor = {
|
|
731
|
+
id: "bg3.stats.equipment-kit-one-body-armor",
|
|
732
|
+
game: "baldursgate3",
|
|
733
|
+
groups: ["stats"],
|
|
734
|
+
modTypes: ["class", "armor"],
|
|
735
|
+
summary: "A Stats/Generated/Equipment.txt kit carries at most one Breast-slot armor: with two, character creation draws the vanilla outfit and the custom one's body mask still hides the arms.",
|
|
736
|
+
appliesTo: projectTarget(),
|
|
737
|
+
appliesToDescription: "the whole project (every Public/<Mod>/Stats/Generated/Equipment.txt)",
|
|
738
|
+
needs: { project: true, index: true },
|
|
739
|
+
cost: "edit",
|
|
740
|
+
severity: "warn",
|
|
741
|
+
evidence: [
|
|
742
|
+
"bg3.equipment.cc-kit-one-body-item",
|
|
743
|
+
"bg3.equipment.cc-kit-override-replaces-vanilla-entry",
|
|
744
|
+
"bg3.items.visualbank.vertexcolormaskslots-underwear-fix",
|
|
745
|
+
],
|
|
746
|
+
async run(ctx) {
|
|
747
|
+
if (ctx.target.kind !== "project")
|
|
748
|
+
return [];
|
|
749
|
+
const blocks = await projectKitBlocks(ctx);
|
|
750
|
+
if (blocks.length === 0)
|
|
751
|
+
return [];
|
|
752
|
+
const projectMap = await buildProjectStatMap(ctx);
|
|
753
|
+
const findings = [];
|
|
754
|
+
for (const block of blocks) {
|
|
755
|
+
const breast = [];
|
|
756
|
+
for (const entry of block.entries) {
|
|
757
|
+
const item = await resolveKitItem(ctx, projectMap, entry.name);
|
|
758
|
+
if (!item || item.type !== "Armor")
|
|
759
|
+
continue;
|
|
760
|
+
const slot = await effectiveFieldAcross(ctx, projectMap, entry.name, "Slot", "Armor");
|
|
761
|
+
if ((slot ?? "").trim().toLowerCase() === "breast")
|
|
762
|
+
breast.push({ entry, where: item.where });
|
|
763
|
+
}
|
|
764
|
+
if (breast.length < 2)
|
|
765
|
+
continue;
|
|
766
|
+
const names = breast.map((b) => `"${b.entry.name}" (${b.where}, line ${b.entry.line})`).join(", ");
|
|
767
|
+
findings.push(finding(equipmentKitOneBodyArmor.id, "warn", block.file, `Kit '${block.name}' carries ${breast.length} Breast-slot armors: ${names}. With two body items in a character-creation kit the preview drew the VANILLA outfit while the custom robe's VertexColorMaskSlots still applied, so the character's arms vanished (observed in a subclass mod, 2026-09-14). Keep exactly one body-slot armor per kit.`, equipmentKitOneBodyArmor.evidence, {
|
|
768
|
+
line: breast[0].entry.line,
|
|
769
|
+
fix: `Drop every Breast-slot entry but the intended one from '${block.name}' (usually the vanilla ARM_<Class> body item).`,
|
|
770
|
+
}));
|
|
771
|
+
}
|
|
772
|
+
return findings;
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
const APPLYSTATUS_SWAP_RE = /\bApplyStatus\(\s*SWAP\b/;
|
|
776
|
+
const onattackSwapTargetsAttacker = {
|
|
777
|
+
id: "bg3.history.onattack-swap-targets-attacker",
|
|
778
|
+
game: "baldursgate3",
|
|
779
|
+
groups: ["history"],
|
|
780
|
+
modTypes: ["dialogue"],
|
|
781
|
+
summary: "In an OnAttack PassiveData, ApplyStatus(SWAP,...) lands the status on the attacker (the wearer), not the struck enemy — almost always a mistake.",
|
|
782
|
+
appliesTo: fileTarget(isStatsFile),
|
|
783
|
+
appliesToDescription: "every Stats/Generated/Data/*.txt file",
|
|
784
|
+
needs: { project: true },
|
|
785
|
+
cost: "edit",
|
|
786
|
+
severity: "warn",
|
|
787
|
+
evidence: ["bg3.boosts.onattack-functors-target-the-struck-creature-swap-flips-to-the-attacker"],
|
|
788
|
+
async run(ctx) {
|
|
789
|
+
if (ctx.target.kind !== "file")
|
|
790
|
+
return [];
|
|
791
|
+
const text = await ctx.readText(ctx.target.path);
|
|
792
|
+
const entries = parseStats(text, ctx.target.path);
|
|
793
|
+
const findings = [];
|
|
794
|
+
for (const entry of entries) {
|
|
795
|
+
if (entry.type !== "PassiveData")
|
|
796
|
+
continue;
|
|
797
|
+
const context = (entry.fields.StatsFunctorContext ?? "").split(";").map((s) => s.trim());
|
|
798
|
+
if (!context.includes("OnAttack"))
|
|
799
|
+
continue;
|
|
800
|
+
const functors = entry.fields.StatsFunctors ?? "";
|
|
801
|
+
if (!APPLYSTATUS_SWAP_RE.test(functors))
|
|
802
|
+
continue;
|
|
803
|
+
findings.push(finding(onattackSwapTargetsAttacker.id, "warn", ctx.target.path, `PassiveData '${entry.name}' has StatsFunctorContext "OnAttack" and applies a status to SWAP. In OnAttack, the default target is the creature that was hit and SELF is the attacker, so SWAP sends the status to the ATTACKER (the wearer) — observed in game in a subclass mod (2026-09-19). Drop the SWAP specifier to land it on the struck enemy, or use SELF if the attacker is truly intended.`, onattackSwapTargetsAttacker.evidence, {
|
|
804
|
+
line: entry.line,
|
|
805
|
+
fix: `In '${entry.name}'.StatsFunctors, change ApplyStatus(SWAP,<status>,...) to ApplyStatus(<status>,...) to target the struck creature (vanilla UND_HARDWORK_TARGET, MAG_ELECTROCUTED).`,
|
|
806
|
+
}));
|
|
807
|
+
}
|
|
808
|
+
return findings;
|
|
809
|
+
},
|
|
810
|
+
};
|
|
811
|
+
const CONDITIONAL_DAMAGEBONUS_RE = /IF\s*\((?:[^()]|\([^()]*\))*\)\s*:\s*DamageBonus\s*\(/;
|
|
812
|
+
const conditionalDamagebonusBareDice = {
|
|
813
|
+
id: "bg3.history.conditional-damagebonus-bare-dice-tooltip",
|
|
814
|
+
game: "baldursgate3",
|
|
815
|
+
groups: ["history"],
|
|
816
|
+
modTypes: ["dialogue"],
|
|
817
|
+
summary: "A conditional DamageBonus written straight into an Armor/Weapon Boosts prints as a bare dice line with no sentence; move it to a PassiveData with a Description.",
|
|
818
|
+
appliesTo: fileTarget(isStatsFile),
|
|
819
|
+
appliesToDescription: "Armor.txt and Weapon.txt",
|
|
820
|
+
needs: { project: true },
|
|
821
|
+
cost: "edit",
|
|
822
|
+
severity: "warn",
|
|
823
|
+
evidence: ["bg3.boosts.conditional-damagebonus-in-item-boosts-renders-as-bare-dice"],
|
|
824
|
+
async run(ctx) {
|
|
825
|
+
if (ctx.target.kind !== "file")
|
|
826
|
+
return [];
|
|
827
|
+
if (!/\/(armor|weapon)\.txt$/.test(lower(ctx.target.path)))
|
|
828
|
+
return [];
|
|
829
|
+
const text = await ctx.readText(ctx.target.path);
|
|
830
|
+
const entries = parseStats(text, ctx.target.path);
|
|
831
|
+
const findings = [];
|
|
832
|
+
for (const entry of entries) {
|
|
833
|
+
if (entry.type !== "Armor" && entry.type !== "Weapon")
|
|
834
|
+
continue;
|
|
835
|
+
for (const field of ["Boosts", "DefaultBoosts"]) {
|
|
836
|
+
const value = entry.fields[field];
|
|
837
|
+
if (!value || !CONDITIONAL_DAMAGEBONUS_RE.test(value))
|
|
838
|
+
continue;
|
|
839
|
+
findings.push(finding(conditionalDamagebonusBareDice.id, "warn", ctx.target.path, `${entry.type} '${entry.name}' has a conditional DamageBonus in its ${field} (IF(...):DamageBonus(...)). This works mechanically but the item tooltip renders it as a bare dice line ("1d4") with no explanatory sentence. Vanilla keeps such riders in a PassiveData with a DisplayName/Description (SCL_MastiffPoachers_Ring_Passive) granted through PassivesOnEquip, so the tooltip shows a card instead.`, conditionalDamagebonusBareDice.evidence, {
|
|
840
|
+
line: entry.line,
|
|
841
|
+
fix: `Move the conditional DamageBonus out of '${entry.name}'.${field} into a PassiveData with a DisplayName/Description, and grant it with PassivesOnEquip "<passive>".`,
|
|
842
|
+
}));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return findings;
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
const createexplosionProjectileRenderChain = {
|
|
849
|
+
id: "bg3.history.createexplosion-projectile-needs-render-chain",
|
|
850
|
+
game: "baldursgate3",
|
|
851
|
+
groups: ["history"],
|
|
852
|
+
modTypes: ["dialogue"],
|
|
853
|
+
summary: "A from-scratch Projectile (no `using`) fired via CreateExplosion deals damage but renders nothing; base it on Projectile_Fireball to keep a rendering chain.",
|
|
854
|
+
appliesTo: fileTarget(isStatsFile),
|
|
855
|
+
appliesToDescription: "every Stats/Generated/Data/*.txt file",
|
|
856
|
+
needs: { project: true },
|
|
857
|
+
cost: "edit",
|
|
858
|
+
severity: "warn",
|
|
859
|
+
evidence: ["bg3.spells.createexplosion-projectile-needs-a-donor-with-a-rendering-effect-chain"],
|
|
860
|
+
async run(ctx) {
|
|
861
|
+
if (ctx.target.kind !== "file")
|
|
862
|
+
return [];
|
|
863
|
+
const text = await ctx.readText(ctx.target.path);
|
|
864
|
+
const projectiles = parseStats(text, ctx.target.path).filter((e) => e.type === "SpellData" && e.fields.SpellType === "Projectile" && !e.using);
|
|
865
|
+
if (projectiles.length === 0)
|
|
866
|
+
return [];
|
|
867
|
+
const explosionCallArgs = await projectExplosionCallArgs(ctx);
|
|
868
|
+
const findings = [];
|
|
869
|
+
for (const entry of projectiles) {
|
|
870
|
+
if (!isExplosionOnlyInvoked(explosionCallArgs, entry.name))
|
|
871
|
+
continue;
|
|
872
|
+
findings.push(finding(createexplosionProjectileRenderChain.id, "warn", ctx.target.path, `SpellData '${entry.name}' is a Projectile with no 'using' parent, fired from a project CreateExplosion(...) Lua call. A from-scratch projectile fired this way deals its damage silently — no effect, no sound (observed in a subclass mod, 2026-09-17). Base it on a donor whose CreateExplosion chain renders (using "Projectile_Fireball", with SpellRoll/SpellSuccess/SpellFail/UseCosts blanked and SpellProperties/ExplodeRadius/TargetConditions overridden) so Fireball's CastSound and HitEffect carry over.`, createexplosionProjectileRenderChain.evidence, {
|
|
873
|
+
line: entry.line,
|
|
874
|
+
fix: `Give '${entry.name}' a using parent with a confirmed rendering chain (using "Projectile_Fireball"), blanking SpellRoll/SpellSuccess/SpellFail/UseCosts and overriding SpellProperties/ExplodeRadius/TargetConditions.`,
|
|
875
|
+
}));
|
|
876
|
+
}
|
|
877
|
+
return findings;
|
|
878
|
+
},
|
|
879
|
+
};
|
|
880
|
+
const ITEMCOMBOS_PATH_RE = /\/public\/[^/]+\/stats\/generated\/itemcombos\.txt$/;
|
|
881
|
+
const isItemCombosFile = (p) => ITEMCOMBOS_PATH_RE.test(p);
|
|
882
|
+
const COMBO_NEW_RE = /^new ItemCombination "([^"]*)"$/;
|
|
883
|
+
const COMBO_RESULT_NEW_RE = /^new ItemCombinationResult "([^"]*)"$/;
|
|
884
|
+
const COMBO_DATA_RE = /^data "(Type|Object) (\d+)" "(.*)"$/;
|
|
885
|
+
export function parseItemCombinations(text, file) {
|
|
886
|
+
const normalized = (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
887
|
+
const combos = [];
|
|
888
|
+
let current;
|
|
889
|
+
const ingredientAt = (combo, index, line) => {
|
|
890
|
+
let ing = combo.ingredients.find((i) => i.index === index);
|
|
891
|
+
if (!ing) {
|
|
892
|
+
ing = { index, line };
|
|
893
|
+
combo.ingredients.push(ing);
|
|
894
|
+
}
|
|
895
|
+
return ing;
|
|
896
|
+
};
|
|
897
|
+
normalized.split("\n").forEach((raw, i) => {
|
|
898
|
+
const line = raw.trim();
|
|
899
|
+
if (line.length === 0 || line.startsWith("//"))
|
|
900
|
+
return;
|
|
901
|
+
const start = COMBO_NEW_RE.exec(line);
|
|
902
|
+
if (start) {
|
|
903
|
+
current = { name: start[1] ?? "", line: i + 1, file, ingredients: [] };
|
|
904
|
+
combos.push(current);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (COMBO_RESULT_NEW_RE.test(line)) {
|
|
908
|
+
current = undefined;
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (!current)
|
|
912
|
+
return;
|
|
913
|
+
const data = COMBO_DATA_RE.exec(line);
|
|
914
|
+
if (!data)
|
|
915
|
+
return;
|
|
916
|
+
const [, key, nStr, value] = data;
|
|
917
|
+
const index = Number(nStr);
|
|
918
|
+
const ing = ingredientAt(current, index, i + 1);
|
|
919
|
+
if (key === "Type")
|
|
920
|
+
ing.type = value;
|
|
921
|
+
else
|
|
922
|
+
ing.value = value;
|
|
923
|
+
});
|
|
924
|
+
for (const combo of combos)
|
|
925
|
+
combo.ingredients.sort((a, b) => a.index - b.index);
|
|
926
|
+
return combos;
|
|
927
|
+
}
|
|
928
|
+
async function collectItemCombinations(ctx) {
|
|
929
|
+
const files = await projectFiles(ctx.project, isItemCombosFile);
|
|
930
|
+
const out = [];
|
|
931
|
+
for (const f of files) {
|
|
932
|
+
try {
|
|
933
|
+
out.push(...parseItemCombinations(await ctx.readText(f), f));
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return out;
|
|
939
|
+
}
|
|
940
|
+
function hasCombineAction(node) {
|
|
941
|
+
const peace = child(node, "OnUsePeaceActions");
|
|
942
|
+
if (!peace)
|
|
943
|
+
return false;
|
|
944
|
+
return children(peace, "Action").some((a) => attr(a, "ActionType") === "23");
|
|
945
|
+
}
|
|
946
|
+
const itemcomboCombineAction = {
|
|
947
|
+
id: "bg3.stats.itemcombo-combine-action",
|
|
948
|
+
game: "baldursgate3",
|
|
949
|
+
groups: ["stats"],
|
|
950
|
+
modTypes: ["dialogue"],
|
|
951
|
+
summary: "An ItemCombination Object ingredient whose RootTemplate chain has no ActionType 23 peace action gets no Combine context-menu entry, so the recipe is unreachable.",
|
|
952
|
+
appliesTo: projectTarget(),
|
|
953
|
+
appliesToDescription: "once per project, over Public/<Mod>/Stats/Generated/ItemCombos.txt",
|
|
954
|
+
needs: { project: true },
|
|
955
|
+
cost: "edit",
|
|
956
|
+
severity: "warn",
|
|
957
|
+
evidence: ["bg3.itemcombos.combine-menu-comes-from-the-template-peace-action-not-the-recipe"],
|
|
958
|
+
async run(ctx) {
|
|
959
|
+
if (ctx.target.kind !== "project")
|
|
960
|
+
return [];
|
|
961
|
+
const combos = await collectItemCombinations(ctx);
|
|
962
|
+
if (combos.length === 0)
|
|
963
|
+
return [];
|
|
964
|
+
const statMap = await buildProjectStatMap(ctx);
|
|
965
|
+
const idx = await collectProjectLsx(ctx);
|
|
966
|
+
const rootTemplates = await collectRootTemplates(idx);
|
|
967
|
+
const rtByMapKey = new Map(rootTemplates.map((rt) => [rt.mapKey, rt]));
|
|
968
|
+
const chainState = (startMapKey) => {
|
|
969
|
+
const visited = new Set();
|
|
970
|
+
let current = rtByMapKey.get(startMapKey);
|
|
971
|
+
while (current) {
|
|
972
|
+
if (visited.has(current.mapKey))
|
|
973
|
+
return "absent";
|
|
974
|
+
visited.add(current.mapKey);
|
|
975
|
+
if (hasCombineAction(current.node))
|
|
976
|
+
return "found";
|
|
977
|
+
if (!current.parentTemplateId)
|
|
978
|
+
return "absent";
|
|
979
|
+
const parent = rtByMapKey.get(current.parentTemplateId);
|
|
980
|
+
if (!parent)
|
|
981
|
+
return "external";
|
|
982
|
+
current = parent;
|
|
983
|
+
}
|
|
984
|
+
return "absent";
|
|
985
|
+
};
|
|
986
|
+
const findings = [];
|
|
987
|
+
for (const combo of combos) {
|
|
988
|
+
for (const ing of combo.ingredients) {
|
|
989
|
+
if (ing.type && ing.type !== "Object")
|
|
990
|
+
continue;
|
|
991
|
+
const statName = ing.value;
|
|
992
|
+
if (!statName)
|
|
993
|
+
continue;
|
|
994
|
+
const entry = lookupStat(statMap, statName);
|
|
995
|
+
if (!entry)
|
|
996
|
+
continue;
|
|
997
|
+
const rtId = entry.fields.RootTemplate;
|
|
998
|
+
if (!rtId)
|
|
999
|
+
continue;
|
|
1000
|
+
if (!rtByMapKey.has(rtId))
|
|
1001
|
+
continue;
|
|
1002
|
+
const state = chainState(rtId);
|
|
1003
|
+
if (state === "found")
|
|
1004
|
+
continue;
|
|
1005
|
+
const unreachable = state === "absent";
|
|
1006
|
+
findings.push(finding(itemcomboCombineAction.id, unreachable ? "warn" : "info", combo.file, unreachable
|
|
1007
|
+
? `ItemCombination '${combo.name}' ingredient '${statName}' resolves to a project RootTemplate whose chain carries no OnUsePeaceActions Action with ActionType 23. An item gets the "Combine" context-menu entry only from that peace action, never from a recipe alone (bisected in-game in a subclass mod, 2026-09-19), so this recipe is unreachable from the UI.`
|
|
1008
|
+
: `ItemCombination '${combo.name}' ingredient '${statName}' resolves to a project RootTemplate with no ActionType 23 peace action, but its ParentTemplateId leaves the project into a template ModWright cannot inspect here. If that vanilla parent is not itself combinable, the recipe is unreachable; verify it carries the Combine action or add one to '${statName}'.`, itemcomboCombineAction.evidence, {
|
|
1009
|
+
line: combo.line,
|
|
1010
|
+
fix: `Add an OnUsePeaceActions Action (ActionType 23; Attributes Animation "", CombineSlots 1 (int8), IsBase True) to '${statName}'s RootTemplate, as on the vanilla Broken Spear halves.`,
|
|
1011
|
+
}));
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return findings;
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
const itemcomboCategoryIsCombocategory = {
|
|
1018
|
+
id: "bg3.stats.itemcombo-category-is-combocategory",
|
|
1019
|
+
game: "baldursgate3",
|
|
1020
|
+
groups: ["stats"],
|
|
1021
|
+
modTypes: ["dialogue"],
|
|
1022
|
+
summary: "A `Type N \"Category\"` ItemCombination ingredient whose value is a mod-defined ComboCategory never matches in-game; name the partner directly with `Type N \"Object\"`.",
|
|
1023
|
+
appliesTo: projectTarget(),
|
|
1024
|
+
appliesToDescription: "once per project, over Public/<Mod>/Stats/Generated/ItemCombos.txt",
|
|
1025
|
+
needs: { project: true },
|
|
1026
|
+
cost: "edit",
|
|
1027
|
+
severity: "block",
|
|
1028
|
+
evidence: ["bg3.itemcombos.category-ingredient-reads-combocategory-and-custom-values-did-not-match"],
|
|
1029
|
+
async run(ctx) {
|
|
1030
|
+
if (ctx.target.kind !== "project")
|
|
1031
|
+
return [];
|
|
1032
|
+
const combos = await collectItemCombinations(ctx);
|
|
1033
|
+
if (combos.length === 0)
|
|
1034
|
+
return [];
|
|
1035
|
+
const statMap = await buildProjectStatMap(ctx);
|
|
1036
|
+
const projectComboCategories = new Set();
|
|
1037
|
+
for (const entry of statMap.values()) {
|
|
1038
|
+
const cc = entry.fields.ComboCategory;
|
|
1039
|
+
if (cc)
|
|
1040
|
+
for (const v of cc.split(";").map((s) => s.trim()))
|
|
1041
|
+
if (v)
|
|
1042
|
+
projectComboCategories.add(v);
|
|
1043
|
+
}
|
|
1044
|
+
const findings = [];
|
|
1045
|
+
for (const combo of combos) {
|
|
1046
|
+
for (const ing of combo.ingredients) {
|
|
1047
|
+
if (ing.type !== "Category" || !ing.value)
|
|
1048
|
+
continue;
|
|
1049
|
+
if (!projectComboCategories.has(ing.value))
|
|
1050
|
+
continue;
|
|
1051
|
+
findings.push(finding(itemcomboCategoryIsCombocategory.id, "block", combo.file, `ItemCombination '${combo.name}' uses a Category ingredient "${ing.value}", which this project defines as a custom ComboCategory. A Category ingredient is matched against the vanilla ComboCategory field, and a mod-defined value never matched a Combine partner in-game (observed in a subclass mod, 2026-09-19), even though it read back correctly on server and client.`, itemcomboCategoryIsCombocategory.evidence, {
|
|
1052
|
+
line: combo.line,
|
|
1053
|
+
fix: `Replace the Category ingredient with Type "Object" naming the partner's stat entry directly (e.g. data "Type ${ing.index}" "Object"; data "Object ${ing.index}" "<statName>").`,
|
|
1054
|
+
}));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return findings;
|
|
1058
|
+
},
|
|
1059
|
+
};
|
|
1060
|
+
export const STATS_VALIDATORS = [
|
|
1061
|
+
spellanimationPresent,
|
|
1062
|
+
islinkedspellcontainerLinks,
|
|
1063
|
+
boostcontextInitialEvent,
|
|
1064
|
+
contextQualifierValidity,
|
|
1065
|
+
handleFormPerField,
|
|
1066
|
+
handleDefinedInLoca,
|
|
1067
|
+
spellidEnumerationDrift,
|
|
1068
|
+
referenceIntegrity,
|
|
1069
|
+
equipmentKitEntriesResolve,
|
|
1070
|
+
equipmentKitOneBodyArmor,
|
|
1071
|
+
onattackSwapTargetsAttacker,
|
|
1072
|
+
conditionalDamagebonusBareDice,
|
|
1073
|
+
createexplosionProjectileRenderChain,
|
|
1074
|
+
itemcomboCombineAction,
|
|
1075
|
+
itemcomboCategoryIsCombocategory,
|
|
1076
|
+
];
|