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)}
+ >
+
e.stopPropagation()}
+ >
+
+
Keyboard Shortcuts
+
+
+
+ {#each shortcutGroups as group}
+
+
{group.title}
+
+ {#each group.items as item}
+
+
{item.label}
+
+ {#each item.keys as key, i}
+ {key}{#if i < item.keys.length - 1}/{/if}
+ {/each}
+
+
+ {/each}
+
+
+ {/each}
+
+
+ Press ?
+ or Esc to close.
+
+
+
+ {/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