diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e335c6..0a6e3aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to wc3-forge are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.5] - 2026-07-06 + +### Added + +- **Keyboard shortcuts + cheat sheet.** World Editor function keys now work: + **F4** opens the Trigger Editor, **F6** the Object Editor, and **F2 / F3 / + F8** switch the viewport editor mode (Doodad / Terrain / Region). Press **`?`** + (or Help → Keyboard Shortcuts) for an overlay listing every binding. + Previously the menu advertised F4/F6 but the keys did nothing. + +### Fixed + +- **Reforged custom object data now loads (units, items, abilities, …).** In + Reforged, a custom object's data is split across two tables per kind: + `war3map.w3*` holds the gameplay fields and `war3mapSkin.w3*` holds the + art/skin overrides — **Name**, **Model File**, **Icon**, **Tooltip**. + wc3-forge read only the first, so every custom unit loaded the right count + but displayed the base type's name + default model. The skin companion tables + are now loaded and merged on top of the base (and preserved losslessly on + save), so custom units show their real name, model, and icon. +- **Custom-skin units render with their overridden model.** A custom unit that + swaps its Model File (e.g. a Peasant-based unit using another model) now + renders that model in the viewport and the Object Editor preview instead of + falling back to the base type's model. +- **Terrain palette populates on first entry.** The ground-tile grid now loads + the moment Terrain mode opens (it could previously show "No ground tiles." on + a map full of terrain), and the empty state names the actual reason — a map + with no terrain file vs. a load error you can retry. + ## [1.0.4] - 2026-06-04 ### Added diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2f056bd..c16c858 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -354,6 +354,46 @@ // tree+field-table render off the DOM until it's actually wanted. let showObjectEditor: boolean = $state(false) let objectEditorInitialId: string | null = $state(null) + // Keyboard-shortcuts cheat sheet overlay. Toggled by '?' (Shift+/) or the + // Help menu — the discoverable index of every binding, aimed at World Editor + // veterans hunting for the F-keys. + let showShortcuts: boolean = $state(false) + // Static content for the cheat sheet. Keep in lockstep with the bindings in + // onGlobalKeyDown (+ the camera-pan keys in camera.ts). + const shortcutGroups: { title: string; items: { label: string; keys: string[] }[] }[] = [ + { title: 'Editors', items: [ + { label: 'Object Editor', keys: ['F6'] }, + { label: 'Trigger Editor', keys: ['F4'] }, + { label: 'Map Info', keys: ['Ctrl+Shift+I'] }, + ] }, + { title: 'Editor mode', items: [ + { label: 'Doodad / Unit mode', keys: ['F2'] }, + { label: 'Terrain mode', keys: ['F3'] }, + { label: 'Region mode', keys: ['F8'] }, + { label: 'Cycle modes', keys: ['Tab', 'Shift+Tab'] }, + ] }, + { title: 'Edit', items: [ + { label: 'Save', keys: ['Ctrl+S'] }, + { label: 'Undo', keys: ['Ctrl+Z'] }, + { label: 'Redo', keys: ['Ctrl+Shift+Z'] }, + { label: 'Delete selection', keys: ['Del', 'Backspace'] }, + ] }, + { title: 'Transform', items: [ + { label: 'Move', keys: ['1'] }, + { label: 'Rotate', keys: ['2'] }, + { label: 'Scale', keys: ['3'] }, + ] }, + { title: 'View & camera', items: [ + { label: 'Pan camera', keys: ['WASD', 'Arrows'] }, + { label: 'Frame selection', keys: ['F'] }, + { label: 'Minimap', keys: ['M'] }, + { label: 'Diagnostics', keys: ['F9'] }, + { label: 'Agent Console', keys: ['Ctrl+`'] }, + ] }, + { title: 'Help', items: [ + { label: 'This cheat sheet', keys: ['?'] }, + ] }, + ] function openObjectEditor(id: string | null = null) { if (!status.loaded) return objectEditorInitialId = id @@ -1486,6 +1526,43 @@ e.preventDefault() toggleDiagnostics() } + // Editor / mode function keys — World Editor muscle memory. Function keys + // aren't text input, so no input-focus gate; bare (no modifiers). F4 → + // Trigger Editor, F6 → Object Editor (matches the classic WE F-key layout + // and the shortcut labels already shown in the menu — previously those + // labels were advertised but the keys were never wired). openTriggerEditor + // / openObjectEditor self-gate on a loaded map. + if (e.key === 'F4' && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) { + e.preventDefault() + openTriggerEditor() + } + if (e.key === 'F6' && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) { + e.preventDefault() + openObjectEditor() + } + // F2 / F3 / F8 switch the viewport editor mode (Doodad / Terrain / Region). + // Like the Tab cycle, these need a loaded map (modes are inert without one). + if ((e.key === 'F2' || e.key === 'F3' || e.key === 'F8') + && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) { + if (!status.loaded) return + e.preventDefault() + setEditorMode(e.key === 'F2' ? 'doodad' : e.key === 'F3' ? 'terrain' : 'region') + } + // Keyboard-shortcuts cheat sheet: '?' (Shift+/ on US layouts). Toggles the + // overlay listing every binding. Skip while typing so '?' still types into + // a field. Escape (handled in the overlay) also dismisses it. + if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { + const tgt = e.target as HTMLElement | null + const tagName = tgt?.tagName?.toLowerCase() + if (tagName === 'input' || tagName === 'textarea' || tgt?.isContentEditable) return + e.preventDefault() + showShortcuts = !showShortcuts + } + // Escape closes the shortcuts overlay when it's the topmost surface. + if (e.key === 'Escape' && showShortcuts) { + e.preventDefault() + showShortcuts = false + } // Frame-selected hotkey: bare 'f'. Centers the camera on the current // selection's centroid and zooms in to fit. Matches the Blender / Maya // / Unity muscle memory. Same input-focus gate as 'M' so typing in the @@ -3109,6 +3186,14 @@ v{appVersion} + { showShortcuts = true })} + title="Show every keyboard shortcut." + > + Keyboard Shortcuts… + ? + + { showAboutDialog = true })} title="About wc3-forge — version, author, and credits." @@ -3779,6 +3864,57 @@ + {#if showShortcuts} + +
(showShortcuts = false)} + > + +
+ {/if} + {#if showMapInfoEditor} { void refresh() }) + function toggleOpen() { open = !open if (open) { @@ -231,6 +251,10 @@
Texture
{#if loading}
Loading tiles…
+ {:else if loadError === 'noterrain'} +
This map has no terrain file (war3map.w3e).
+ {:else if loadError === 'failed'} +
Couldn’t load tiles.
{:else if ground.length === 0}
No ground tiles.
{:else} @@ -455,6 +479,15 @@ letter-spacing: 0.04em; color: var(--muted-foreground); margin-bottom: 6px; } .muted { font-size: 0.75rem; color: var(--muted-foreground); padding: 4px 0; } + .retry-link { + background: none; + border: none; + padding: 0; + color: var(--primary, #60a5fa); + font: inherit; + text-decoration: underline; + cursor: pointer; + } .tile-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; } .tile { diff --git a/frontend/src/scene-instances.ts b/frontend/src/scene-instances.ts index 4fb93e4..b99d21b 100644 --- a/frontend/src/scene-instances.ts +++ b/frontend/src/scene-instances.ts @@ -1682,8 +1682,12 @@ export function createScene(canvas: HTMLCanvasElement, reforged: boolean = false hoveredUnit = null } - // Unit type index: stock-only and process-stable (we don't yet apply - // per-map w3u modifications), so cache for process lifetime. + // Unit type index: carries per-map w3u + war3mapSkin.w3u overrides (display + // name, model file, icon), so it is per-MAP, not process-stable. loadMap() + // nulls the cache below so the next getUnitTypes() re-fetches the current + // map's merged index; within a single load the cache lets concurrent + // placeUnit calls share one fetch. (Mirrors getDoodadTypes, which re-fetches + // per load for the same reason.) let unitTypeIndexCache: Record | null = null async function getUnitTypes(): Promise> { if (!unitTypeIndexCache) { @@ -3509,6 +3513,11 @@ export function createScene(canvas: HTMLCanvasElement, reforged: boolean = false // tallies accumulated up to the failure point. sceneDiag.loadingMap = true sceneDiag.loadGeneration++ + // The unit type index is per-map (it folds in this map's w3u + + // war3mapSkin.w3u name/model/icon overrides), so drop the prior map's + // cached copy — the getUnitTypes() call below re-fetches the current + // map's merged index. + unitTypeIndexCache = null const loadStartTs = performance.now() try { clearInstances() diff --git a/internal/forge/handlers_objects.go b/internal/forge/handlers_objects.go index 97da8f9..55b4bc0 100644 --- a/internal/forge/handlers_objects.go +++ b/internal/forge/handlers_objects.go @@ -151,7 +151,13 @@ func normalizeIconPath(p string) string { // "war3mapImported/foo.mdl") so the .mdl fallback is load-bearing for // custom-content maps even when the asset handler's sibling swap fires. func unitModelPath(fields map[string]string) (string, []string) { - for _, k := range []string{"file", "file:hd", "file:sd"} { + // "umdl" first: the "file" column is shared across several unit FourCCs, so + // a per-map Model File override (umdl) is stored under its own FourCC-keyed + // slot (see ObjectMetadata.storeCol), not under "file". Without checking it, + // the preview shows the base model even though the override is loaded — the + // remaining half of the war3mapSkin.w3u custom-model fix. Falls through to + // the stock "file"/"file:hd"/"file:sd" columns when no override is present. + for _, k := range []string{"umdl", "file", "file:hd", "file:sd"} { v := strings.TrimSpace(fields[k]) if v == "" { continue @@ -203,15 +209,15 @@ func unitIconArt(fields map[string]string) string { type objectsUnitsListEntity struct { ID string `json:"id"` Name string `json:"name"` - Race string `json:"race"` // raw lowercase tag ("human"); frontend titlecases via shared lookup - RaceLabel string `json:"race_label"` // pre-titled label ("Human") - Kind string `json:"kind"` // "unit" | "hero" | "building" | "special" - Category string `json:"category"` // human-readable bucket combining race + kind + Race string `json:"race"` // raw lowercase tag ("human"); frontend titlecases via shared lookup + RaceLabel string `json:"race_label"` // pre-titled label ("Human") + Kind string `json:"kind"` // "unit" | "hero" | "building" | "special" + Category string `json:"category"` // human-readable bucket combining race + kind IsCustom bool `json:"is_custom"` IsEdited bool `json:"is_edited"` BaseID string `json:"base_id,omitempty"` // only set for customs - Campaign bool `json:"campaign"` // hides under "Campaign" subtree when true - IconArt string `json:"icon_art"` // command-button icon path stem + Campaign bool `json:"campaign"` // hides under "Campaign" subtree when true + IconArt string `json:"icon_art"` // command-button icon path stem } type objectsUnitsGetParams struct { @@ -231,7 +237,7 @@ type objectsUnitsField struct { // safe to fall back to Display when an old client doesn't know about this // field. Empty when Value is empty. DisplayRaw string `json:"display_raw"` - Overridden bool `json:"overridden"` // true if this came from the per-map shadow + Overridden bool `json:"overridden"` // true if this came from the per-map shadow // Levels carries per-level override values for leveled (opt-format) kinds: // level (1-based) → raw value. Empty/omitted for non-leveled fields and for // fields with no per-level overrides. The base/level-0 value stays in Value. diff --git a/internal/forge/objects.go b/internal/forge/objects.go index f754557..9612939 100644 --- a/internal/forge/objects.go +++ b/internal/forge/objects.go @@ -304,28 +304,61 @@ func MergedObjects(cfg *KindConfig) (map[string]*MergedObject, *ObjectMetadata, } } - // Layer 2: per-map shadow from the kind's war3map.w3* file. + // Layer 2: per-map shadows, primary-first. The primary war3map.w3* wins on + // any field it sets; the Reforged war3mapSkin.w3* companion then fills the + // art/skin fields (Name unam, Model File umdl, Icon uico, Tooltip utip) the + // primary leaves untouched. Real-world Reforged maps put a custom unit's + // gameplay edits in war3map.w3u and its name/model/icon in war3mapSkin.w3u, + // so without the skin layer every custom unit shows its base type's name + + // model. Both read under one lock acquisition. Current.mu.RLock() mods := cfg.GetMods(Current) + skin := Current.objectSkinMods[cfg.Kind] Current.mu.RUnlock() - if mods != nil { - for _, edit := range mods.OriginalEdits { - u, ok := out[edit.BaseID] - if !ok { - // Original-table entry pointing at a base ID we don't know. - // Fabricate a stub so the override is still visible. - u = &MergedObject{ - ID: edit.BaseID, - Fields: map[string]string{}, - Overridden: map[string]bool{}, - } - out[edit.BaseID] = u + applyModLayer(out, base, mods, meta, false) // primary: authoritative identity + gameplay + applyModLayer(out, base, skin, meta, true) // skin: fill art fields the primary didn't set + + return out, meta, nil +} + +// applyModLayer folds one w3objmod shadow (primary war3map.w3* or its Reforged +// war3mapSkin.w3* companion) into the merged-object map. Shared by the primary +// and skin sub-layers of MergedObjects. +// +// fillOnly = false (primary): OriginalEdits apply in full; each Custom is minted +// fresh from its base row (authoritative identity — matches the pre-skin +// behavior, so a custom ID that shadows a stock row still becomes the custom). +// +// fillOnly = true (skin): overrides only write fields the primary hasn't already +// set (see applyOverrides), and a Custom is created from base ONLY when the +// primary layer didn't already mint it — a skin-only custom. When the primary +// already minted the object, the skin overrides just fill its empty art fields. +func applyModLayer(out map[string]*MergedObject, base *slk.Mapped, mods *w3objmod.File, meta *ObjectMetadata, fillOnly bool) { + if mods == nil { + return + } + for _, edit := range mods.OriginalEdits { + u, ok := out[edit.BaseID] + if !ok { + // Original-table entry pointing at a base ID we don't know. + // Fabricate a stub so the override is still visible. + u = &MergedObject{ + ID: edit.BaseID, + Fields: map[string]string{}, + Overridden: map[string]bool{}, } - applyOverrides(u, edit.Overrides, meta) - applyLevelOverrides(u, edit.Levels, meta) + out[edit.BaseID] = u } - for _, c := range mods.Customs { - u := &MergedObject{ + applyOverrides(u, edit.Overrides, meta, fillOnly) + applyLevelOverrides(u, edit.Levels, meta) + } + for _, c := range mods.Customs { + u, ok := out[c.ID] + if !ok || !fillOnly { + // Mint fresh from the base row. The primary layer always mints + // (authoritative); the skin layer only reaches here for a custom the + // primary didn't define (ok == false). + u = &MergedObject{ ID: c.ID, BaseID: c.BaseID, IsCustom: true, @@ -337,12 +370,11 @@ func MergedObjects(cfg *KindConfig) (map[string]*MergedObject, *ObjectMetadata, u.Fields[col] = val } } - applyOverrides(u, c.Overrides, meta) - applyLevelOverrides(u, c.Levels, meta) out[c.ID] = u } + applyOverrides(u, c.Overrides, meta, fillOnly) + applyLevelOverrides(u, c.Levels, meta) } - return out, meta, nil } // MergedUnits is the Phase-1b read entry point, preserved verbatim as a thin @@ -356,11 +388,10 @@ func MergedUnits() (map[string]*MergedObject, *ObjectMetadata, error) { // writes the values into the merged object's column-name-keyed Fields map. // Identical to Phase 1b — kept on the generic since every kind merges the // same way (FourCC → metadata column lookup → write into Fields). -func applyOverrides(u *MergedObject, ov w3objmod.Overrides, meta *ObjectMetadata) { +func applyOverrides(u *MergedObject, ov w3objmod.Overrides, meta *ObjectMetadata, fillOnly bool) { if len(ov) == 0 { return } - u.IsEdited = true for fourCC, val := range ov { col := fourCC if m, ok := meta.ByID[fourCC]; ok { @@ -371,8 +402,14 @@ func applyOverrides(u *MergedObject, ov w3objmod.Overrides, meta *ObjectMetadata } else if !looksLikeFourCC(fourCC) { col = strings.ToLower(fourCC) } + // fillOnly (the war3mapSkin.w3* companion layer) never clobbers a value + // the primary war3map.w3* shadow already wrote — primary wins. + if fillOnly && u.Overridden[col] { + continue + } u.Fields[col] = val u.Overridden[col] = true + u.IsEdited = true } } @@ -771,14 +808,14 @@ func DestructablesConfig() *KindConfig { AppliesFn: func(f ObjectFieldMeta) bool { return f.AppliesToDestructables() }, - ClassifyFn: classifyByCategoryColumn, - CategoryFn: categoryPassthrough, - IconArtFn: destructableIconArt, + ClassifyFn: classifyByCategoryColumn, + CategoryFn: categoryPassthrough, + IconArtFn: destructableIconArt, ModelPathFn: doodadModelPath, - GetMods: func(s *Session) *w3objmod.File { return s.destructibleMods }, - SetMods: func(s *Session, f *w3objmod.File) { s.destructibleMods = f }, - GetDirty: func(s *Session) bool { return s.dirtyDestructibleMods }, - SetDirty: func(s *Session, v bool) { s.dirtyDestructibleMods = v }, + GetMods: func(s *Session) *w3objmod.File { return s.destructibleMods }, + SetMods: func(s *Session, f *w3objmod.File) { s.destructibleMods = f }, + GetDirty: func(s *Session) bool { return s.dirtyDestructibleMods }, + SetDirty: func(s *Session, v bool) { s.dirtyDestructibleMods = v }, } registerKindConfig(destructablesConfigCfg) }) diff --git a/internal/forge/objects_skin_test.go b/internal/forge/objects_skin_test.go new file mode 100644 index 0000000..004f4e8 --- /dev/null +++ b/internal/forge/objects_skin_test.go @@ -0,0 +1,164 @@ +package forge + +import ( + "testing" + + "github.com/StephenSHorton/wc3-forge/internal/formats/slk" + "github.com/StephenSHorton/wc3-forge/internal/formats/w3objmod" +) + +// stubUnitsBaseWithModel installs a stock "hpea" carrying both a name and a +// model-file column, with unam/umdl/uhpm metadata bound so the merge can route +// each FourCC to its column. Mirrors stubUnitsBase but adds the `file` column +// the skin-overlay tests assert on. +func stubUnitsBaseWithModel(t *testing.T) { + t.Helper() + resetObjectBaseCacheForTest("units") + prevReader := baseAssetReader + baseAssetReader = nil + t.Cleanup(func() { + baseAssetReader = prevReader + resetObjectBaseCacheForTest("units") + }) + stubBase := slk.New() + stubBase.Rows = map[string]slk.MappedRow{ + "hpea": {"name": "Peasant", "file": `units\Human\Peasant\Peasant`, "race": "human"}, + } + meta := &UnitMetadata{ + Fields: []UnitFieldMeta{ + {ID: "unam", Field: "Name", Category: "text", Type: "string", UseUnit: true, UseHero: true, UseBuilding: true}, + {ID: "umdl", Field: "file", Category: "art", Type: "model", UseUnit: true, UseHero: true, UseBuilding: true}, + {ID: "uhpm", Field: "HP", Category: "stats", Type: "int", UseUnit: true, UseHero: true, UseBuilding: true}, + }, + ByID: map[string]*UnitFieldMeta{}, + } + for i := range meta.Fields { + meta.ByID[meta.Fields[i].ID] = &meta.Fields[i] + } + setObjectBaseForTest("units", stubBase, meta) +} + +// TestMergedObjects_SkinOverlay_FillsNameAndModel reproduces the Reforged split +// that made every custom unit show as its base type: a custom unit's gameplay +// edit lives in war3map.w3u while its Name + Model File live only in +// war3mapSkin.w3u. The merge must surface the skin's name/model on top of the +// base while preserving the primary table's gameplay edit. +func TestMergedObjects_SkinOverlay_FillsNameAndModel(t *testing.T) { + stubUnitsBaseWithModel(t) + + prevCurrent := Current + t.Cleanup(func() { Current = prevCurrent }) + + // Primary war3map.w3u: custom h000 (based on Peasant) carries only a + // gameplay edit (HP) — exactly how Reforged authors the split. + primary := &w3objmod.File{ + Version: 3, + Customs: []w3objmod.CustomObject{ + {ID: "h000", BaseID: "hpea", Overrides: w3objmod.Overrides{"uhpm": "240"}}, + }, + } + // war3mapSkin.w3u: the SAME custom carries the name + model overrides that + // wc3-forge previously ignored. + skin := &w3objmod.File{ + Version: 3, + Customs: []w3objmod.CustomObject{ + {ID: "h000", BaseID: "hpea", Overrides: w3objmod.Overrides{ + "unam": "Sylvanas", + "umdl": `units\Human\Sylvanas\Sylvanas`, + }}, + }, + } + Current = &Session{ + unitMods: primary, + objectSkinMods: map[string]*w3objmod.File{"units": skin}, + } + + out, _, err := MergedObjects(UnitsConfig()) + if err != nil { + t.Fatalf("MergedObjects: %v", err) + } + u, ok := out["h000"] + if !ok { + t.Fatal("custom h000 missing from merge") + } + if !u.IsCustom || u.BaseID != "hpea" { + t.Fatalf("identity wrong: IsCustom=%v BaseID=%q (want true/hpea)", u.IsCustom, u.BaseID) + } + if u.Fields["name"] != "Sylvanas" { + t.Errorf("skin name not applied: got %q want %q", u.Fields["name"], "Sylvanas") + } + if got := u.Fields["file"]; got != `units\Human\Sylvanas\Sylvanas` { + t.Errorf("skin model not applied: got %q", got) + } + if u.Fields["hp"] != "240" { + t.Errorf("primary gameplay edit lost: got %q want 240", u.Fields["hp"]) + } +} + +// TestUnitModelPath_UmdlSharedSlotOverride verifies the preview model honors a +// Model File override stored under the FourCC-keyed "umdl" slot (the "file" +// column is shared, so storeCol files umdl there, not under "file"), and still +// falls through to the stock "file" column when there's no override. +func TestUnitModelPath_UmdlSharedSlotOverride(t *testing.T) { + mdx, fb := unitModelPath(map[string]string{ + "umdl": `war3mapImported\Sylvanas.mdl`, + "file": `units\Human\Peasant\Peasant`, + }) + if mdx != `war3mapImported\Sylvanas.mdx` { + t.Errorf("umdl override ignored by preview: got %q", mdx) + } + if len(fb) == 0 || fb[0] != `war3mapImported\Sylvanas.mdl` { + t.Errorf("umdl override .mdl fallback wrong: got %v", fb) + } + mdx2, _ := unitModelPath(map[string]string{"file": `units\Human\Peasant\Peasant`}) + if mdx2 != `units\Human\Peasant\Peasant.mdx` { + t.Errorf("stock file fallback broken: got %q", mdx2) + } +} + +// TestMergedObjects_PrimaryWinsOverSkin locks in the precedence contract: when +// the same field is set in BOTH tables, the primary war3map.w3u wins and the +// skin only fills what the primary left unset. This keeps edits made in +// wc3-forge (which route to the primary table) from being masked by the skin. +func TestMergedObjects_PrimaryWinsOverSkin(t *testing.T) { + stubUnitsBaseWithModel(t) + + prevCurrent := Current + t.Cleanup(func() { Current = prevCurrent }) + + primary := &w3objmod.File{ + Version: 3, + Customs: []w3objmod.CustomObject{ + {ID: "h001", BaseID: "hpea", Overrides: w3objmod.Overrides{"unam": "PrimaryName"}}, + }, + } + skin := &w3objmod.File{ + Version: 3, + Customs: []w3objmod.CustomObject{ + {ID: "h001", BaseID: "hpea", Overrides: w3objmod.Overrides{ + "unam": "SkinName", + "umdl": `units\Human\Sylvanas\Sylvanas`, + }}, + }, + } + Current = &Session{ + unitMods: primary, + objectSkinMods: map[string]*w3objmod.File{"units": skin}, + } + + out, _, err := MergedObjects(UnitsConfig()) + if err != nil { + t.Fatalf("MergedObjects: %v", err) + } + u := out["h001"] + if u == nil { + t.Fatal("custom h001 missing from merge") + } + if u.Fields["name"] != "PrimaryName" { + t.Errorf("primary should win on a shared field: got %q want PrimaryName", u.Fields["name"]) + } + // The skin still fills the model the primary didn't set. + if got := u.Fields["file"]; got != `units\Human\Sylvanas\Sylvanas` { + t.Errorf("skin should fill the unset model: got %q", got) + } +} diff --git a/internal/forge/objects_spellbook_test.go b/internal/forge/objects_spellbook_test.go index 13a464a..6870490 100644 --- a/internal/forge/objects_spellbook_test.go +++ b/internal/forge/objects_spellbook_test.go @@ -231,7 +231,7 @@ func TestApplyOverrides_SharedColumnKeyedByFourCC(t *testing.T) { "spb1": "list", "spb3": "3", "anam": "Book", - }, meta) + }, meta, false) // Shared fields land in distinct FourCC slots, not one "data" slot. if u.Fields["spb1"] != "list" { diff --git a/internal/forge/session.go b/internal/forge/session.go index 11265fd..a820ff7 100644 --- a/internal/forge/session.go +++ b/internal/forge/session.go @@ -248,17 +248,28 @@ type Session struct { abilityMods *w3objmod.File // war3map.w3a (opt=true) buffMods *w3objmod.File // war3map.w3h upgradeMods *w3objmod.File // war3map.w3q (opt=true) - shadowMap *shd.File // war3map.shd - pathingMap *wpm.File // war3map.wpm - strings wts.Strings // war3map.wts, for TRIGSTR_ resolution + // objectSkinMods holds the Reforged "skin" companion tables + // (war3mapSkin.w3u/.w3t/.w3a/.w3b/.w3d/.w3h/.w3q), keyed by KindConfig.Kind + // ("units", "items", ...). Reforged's World Editor splits per-map object + // overrides across two files per kind: war3map.w3* carries gameplay/logic + // edits, war3mapSkin.w3* carries the art/skin overrides (Name unam, Model + // File umdl, Icon uico, Tooltip utip). We load these read-only and merge + // them UNDER the primary shadow (primary wins; skin fills fields the primary + // doesn't set) so custom units surface their real name + model instead of + // the base type's. Never edited in place, so Save preserves them verbatim + // via the lossless copy-through — there is no dirty flag for them. + objectSkinMods map[string]*w3objmod.File + shadowMap *shd.File // war3map.shd + pathingMap *wpm.File // war3map.wpm + strings wts.Strings // war3map.wts, for TRIGSTR_ resolution // infoTokens maps a Map Info Description field key ("name"/"author"/ // "description"/"suggestedPlayers") to its ORIGINAL "TRIGSTR_" token, // captured at Open before ResolveStrings resolved the field to its display // value. Lets a Map Info edit update the referenced wts entry (rather than // orphaning it) and lets Save re-inject the token into w3i. nil/empty for a // non-localized map. See info_trigstr.go. - infoTokens map[string]string - gameplay *miscdata.File // war3mapMisc.txt — per-map gameplay-constants overrides + infoTokens map[string]string + gameplay *miscdata.File // war3mapMisc.txt — per-map gameplay-constants overrides // regions / cameras — OPTIONAL. Phase 2b2 added Parse-only support so the // Trigger Editor's gg_rct_*/gg_cam_* entity-name resolver + the region / @@ -698,6 +709,29 @@ func (s *Session) openWithSource(abs string, src fileSource, rawMapBytes []byte, return err } + // war3mapSkin.w3{d,b,u,t,a,h,q} — OPTIONAL Reforged "skin" companion tables. + // Reforged's World Editor writes the art/skin overrides (Name unam, Model + // File umdl, Icon uico, Tooltip utip) into these siblings of the plain + // war3map.w3* tables. Loaded read-only, keyed by kind, and merged UNDER the + // primary shadow at read time (see MergedObjects / mergeUnitIndex). Same + // per-kind extension + opt flag as the primary table, derived from each + // registered KindConfig so this stays in lockstep as kinds are added. + skinMods := map[string]*w3objmod.File{} + for _, kc := range kindConfigs { + skinName := strings.Replace(kc.ShadowFile, "war3map.", "war3mapSkin.", 1) + parse := parseFlat + if kc.ShadowOpt { + parse = parseOpt + } + f, err := readOpt(src, skinName, parse) + if err != nil { + return err + } + if f != nil { + skinMods[kc.Kind] = f + } + } + // war3mapMisc.txt — OPTIONAL per-map gameplay-constants overrides. Maps // without this file inherit the stock MiscData.txt values; nothing to // load. The editor exposes whatever overrides are present + lets the @@ -766,6 +800,7 @@ func (s *Session) openWithSource(abs string, src fileSource, rawMapBytes []byte, s.abilityMods = abilityMods s.buffMods = buffMods s.upgradeMods = upgradeMods + s.objectSkinMods = skinMods s.shadowMap = shadowMap s.pathingMap = pathingMap s.regions = regionsFile @@ -924,6 +959,7 @@ func (s *Session) Close() { s.abilityMods = nil s.buffMods = nil s.upgradeMods = nil + s.objectSkinMods = nil s.shadowMap = nil s.pathingMap = nil s.regions = nil @@ -1102,6 +1138,22 @@ func (s *Session) ItemMods() *w3objmod.File { return s.itemMods } +// SkinMods returns the parsed war3mapSkin.w3* companion table for the given +// KindConfig.Kind ("units", "items", ...), or nil if the map has none. These +// are the Reforged art/skin overrides (Name, Model File, Icon) that the +// renderer + Object Editor layer UNDER the primary shadow. +func (s *Session) SkinMods(kind string) *w3objmod.File { + s.mu.RLock() + defer s.mu.RUnlock() + return s.objectSkinMods[kind] +} + +// UnitSkinMods returns war3mapSkin.w3u (unit art/skin overrides), or nil. +func (s *Session) UnitSkinMods() *w3objmod.File { return s.SkinMods("units") } + +// ItemSkinMods returns war3mapSkin.w3t (item art/skin overrides), or nil. +func (s *Session) ItemSkinMods() *w3objmod.File { return s.SkinMods("items") } + // AbilityMods returns the parsed war3map.w3a (per-map ability modifications + // new derived abilities), or nil if absent. Phase 2b accessor. func (s *Session) AbilityMods() *w3objmod.File { diff --git a/typeindex.go b/typeindex.go index dcc3906..5fae83f 100644 --- a/typeindex.go +++ b/typeindex.go @@ -547,17 +547,19 @@ var doodadFieldMap = map[string]string{ // their SLK column names. We only consume display-name overrides for now; // expand as the renderer surfaces more fields. var unitFieldMap = map[string]string{ - "unam": "name", // unit name override (w3u) - "unsf": "editorsuffix", - "utub": "tilesets", - "urac": "race", - "ucla": "unitclass", - "unam2": "name", // item name (w3t variant) + "unam": "name", // unit name override (w3u) + "umdl": "file", // unit model-file override (w3u / war3mapSkin.w3u) — custom skins + "usca": "modelscale", // unit scaling value (Art - Scaling Value) + "unsf": "editorsuffix", + "utub": "tilesets", + "urac": "race", + "ucla": "unitclass", + "unam2": "name", // item name (w3t variant) "unam_item": "name", - "unin": "description", - "inam": "name", // item name (w3t) - "uico": "art", // unit command-button icon override (w3u) - "iico": "art", // item icon override (w3t) + "unin": "description", + "inam": "name", // item name (w3t) + "uico": "art", // unit command-button icon override (w3u) + "iico": "art", // item icon override (w3t) } func resolveColumn(modID string, fields map[string]string) string { @@ -608,6 +610,20 @@ func applyUnitOverrides(base UnitTypeInfo, ov w3objmod.Overrides, mapStrings wts switch strings.ToLower(col) { case "name": out.Name = resolveDisplay(val, mapStrings) + case "file": + // Custom units can point at a different model — a per-map skin swap + // (e.g. a Peasant-based unit using the Sylvanas model). Without this + // the viewport falls back to the base type's model, the exact + // mismatch users see vs the World Editor. UnitTypeInfo.File is a stem + // (placeUnit re-appends ".mdx" via mdxPath), so strip any extension + // the override carries. Empty override keeps the base model. + if s := trimModelExt(strings.TrimSpace(val)); s != "" { + out.File = s + } + case "modelscale": + // Art - Scaling Value (usca). Only applies when the override is + // present; absent leaves the stock scale (default 1). + out.ModelScale = parseFloat(val, base.ModelScale) case "art": // Per-map custom units may declare their own command-button icon // (war3map.w3u uart field). Lets a map override the Footman @@ -618,6 +634,19 @@ func applyUnitOverrides(base UnitTypeInfo, ov w3objmod.Overrides, mapStrings wts return out } +// trimModelExt strips a trailing .mdx/.mdl (case-insensitive) so a model-file +// override collapses to the extension-less stem UnitTypeInfo.File holds +// (placeUnit re-appends ".mdx"). Leaves a stem without extension untouched. +func trimModelExt(s string) string { + if i := strings.LastIndexByte(s, '.'); i > 0 { + switch strings.ToLower(s[i:]) { + case ".mdx", ".mdl": + return s[:i] + } + } + return s +} + func parseInt(s string, fallback int) int { // w3objmod serialises float modifications as "1.5" even when the SLK // column is conceptually an int; ParseFloat-then-truncate covers both. @@ -673,36 +702,57 @@ func mergeDoodadIndex() map[string]DoodadTypeInfo { return out } -// mergeUnitIndex applies per-map w3u (units) + w3t (items) modifications. -// Currently only display-name overrides flow through — the renderer doesn't -// consume per-unit SLK overrides for its other fields yet. +// mergeUnitIndex applies per-map w3u (units) + w3t (items) modifications, +// including their Reforged war3mapSkin.w3* companions. Display name, model +// file (umdl), model scale (usca), and icon (uico) overrides flow through so +// custom-skin units render with their real model instead of the base type's. func mergeUnitIndex() map[string]UnitTypeInfo { out := make(map[string]UnitTypeInfo, len(unitIndex)) for k, v := range unitIndex { out[k] = v } mapStrings := forge.Current.Strings() + // Apply the war3mapSkin.w3* companion first (lower precedence), then the + // primary war3map.w3* on top — a field set in both resolves to the primary + // value, while art/skin fields present only in the skin table (the common + // Reforged case: custom name/model/icon) still flow through. Mirrors the + // primary-wins merge in forge.MergedObjects. + for _, mods := range []*w3objmod.File{forge.Current.UnitSkinMods(), forge.Current.ItemSkinMods()} { + applyUnitModsLayer(out, mods, mapStrings) + } for _, mods := range []*w3objmod.File{forge.Current.UnitMods(), forge.Current.ItemMods()} { - if mods == nil { + applyUnitModsLayer(out, mods, mapStrings) + } + return out +} + +// applyUnitModsLayer folds one w3objmod shadow (a primary war3map.w3* table or +// its war3mapSkin.w3* companion) into the unit type index. Customs apply onto +// the entry a prior layer already minted (so skin-then-primary accumulates) +// or, on first sight, onto a fresh copy of their base type. +func applyUnitModsLayer(out map[string]UnitTypeInfo, mods *w3objmod.File, mapStrings wts.Strings) { + if mods == nil { + return + } + for _, edit := range mods.OriginalEdits { + base, ok := out[edit.BaseID] + if !ok { continue } - for _, edit := range mods.OriginalEdits { - base, ok := out[edit.BaseID] - if !ok { - continue - } - out[edit.BaseID] = applyUnitOverrides(base, edit.Overrides, mapStrings) - } - for _, c := range mods.Customs { - base, ok := out[c.BaseID] - if !ok { + out[edit.BaseID] = applyUnitOverrides(base, edit.Overrides, mapStrings) + } + for _, c := range mods.Customs { + cur, ok := out[c.ID] + if !ok { + base, bok := out[c.BaseID] + if !bok { log.Printf("typeindex: custom unit %s missing base %s; skipping", c.ID, c.BaseID) continue } - out[c.ID] = applyUnitOverrides(base, c.Overrides, mapStrings) + cur = base } + out[c.ID] = applyUnitOverrides(cur, c.Overrides, mapStrings) } - return out } // GetUnitTypeIndex returns the full FourCC → UnitTypeInfo map. Per-map