[GUIDE] Fixing SLK Maps That Crash on Reforged 3.0.0 (build 24268)

Cheatpacks and learning how to use them, as well as other guides for manipulating maps.

Moderator: Cheaters

User avatar
devoltz
Co-Admin
Posts: 3188
Joined: March 23rd, 2016, 8:06 pm
Has thanked: 16 times
Been thanked: 73 times

[GUIDE] Fixing SLK Maps That Crash on Reforged 3.0.0 (build 24268)

Post by devoltz »

Patch 3.0.0 breaks most maps made with KKWE or YDWE, especially those that override Units\*.slk directly instead of packaging a standard war3map.w3u it just strictly rejects old data formats that previous versions let slide.

I figured this out while debugging a 380 MB SLK RPG, doing one launch per test (which is as painful as it sounds). The script at the end of this post will automate these fixes for you, but it helps to understand what's actually breaking.

Symptoms You Might Be Seeing
  • The game flat-out closes on the loading screen after a couple of seconds. No error message.
  • The map loads, but the game crashes the exact moment the first unit is created (even a basic footman).
  • Players report: "It loads, but the map is completely empty and there are no teams." This is the same crash as above, just happening during initialization while units are spawning.
  • Abilities break once they go above level 4: hero skills vanish from the command buttons, or damage and other values read as 0. See Problem 5.
Step 1: Check the Logs First

Your crash logs are located here: Documents\Warcraft III\Errors\<date>\. Look for War3Log.txt and Crash.txt. Here is how to read them effectively:
  1. Compare your new log with a log from an older game version where the map actually worked. Look for new lines that stand out. This is exactly how I found the "(UI\template.fdf//129) Error, token "*" invalid" bug.
  2. Look at the crash stack. If two crashes have the same instruction and stack trace, it's the exact same crash site, regardless of what the error text says above it.
What if the log is completely empty?

If you get the "first unit" crash, the log usually won't tell you anything. You have to force the map to write its own trace. You can do this by making each initialization step write to a text file right before it executes:

Code: Select all

call PreloadGenClear()
call PreloadGenStart()
call Preload("step: CreateUnits / unit 143 n04K")
call PreloadGenEnd("CustomMapData\\MyMap\\trace.pld")
Since each step overwrites the file, it will always contain the very last successful step before the crash. From there, just swap files one by one using an MPQ editor and launch the game. One launch answers one question until you narrow it down.

Problem 1: The "file" column in Units\UnitUI.slk and Units\ItemData.slk (The Main Crash)

Since patch 1.32, art fields were moved to Units\UnitSkin.txt and Units\ItemSkin.txt. The native UnitUI.slk that ships with 3.0.0 doesn't even have a file column anymore.

However, old SLK maps replace this table with an outdated copy that still declares models in the file column. Older versions ignored this extra column. Patch 3.0.0 actually tries to read it, gets a -1, uses it as an index, and crashes instantly. ItemData.slk suffers from the exact same issue. Since items are built before units, the unit crash usually triggers first and gets all the blame.

The Fix: Delete the file column entirely from both SLK files, and move your custom models into the corresponding Skin text files like this:

Code: Select all

[h01A]
file=MyModels\Paladin.mdl
Why does this work? A .txt profile simply merges with existing game data, while an .slk replaces the entire table. By using the Skin files, you only need to declare your custom units, and all base game units will continue to work perfectly.

(Alternatively, you can just build a proper war3map.w3u and use the umdl field per unit. Just don't use both methods at once. Also, if your script sets models at runtime using DzSetUnitModel, make sure to rebuild the table it reads from the Skin files, as it needs those model paths to function).

Problem 2: Numbers stored as quoted text

Sometimes map data looks like this:

Code: Select all

walk="280."   shadowW="140."   scale="1."
I'll be honest: this doesn't actually cause a crash. I spent a lot of time chasing this down thinking it was the culprit. It isn't. The map I fixed still has 236 of these in UnitUI.slk and loads completely fine on 3.0.0. I'm including it because the script cleans it up (clean data is good data), but this isn't what's breaking your map.

If you clean this manually, don't rely on a fixed list of numeric columns—modded maps invent their own. Check every column, and if almost every value already parses as a number, convert the quoted ones.

Problem 3: Incomplete Button Positions

Older editors used to drop the zero in UI coordinates, which messes up the grid in newer patches:

Code: Select all

Buttonpos=,2          // Should be 0,2
Researchbuttonpos=1   // Should be 1,0
(key missing)         // Defaults to 0,0
Watch out for Buttonpos=,-11. This isn't a typo; it's a classic modding trick to hide a button by shoving it entirely outside the 4x3 grid. Make sure you complete this to 0,-11, not 0,0.

Problem 4: Stray "*/" in .fdf files

If you have a block ending in }*/ without a starting /* anywhere in the file, patch 3.0.0 will reject the token and crash your game on the loading screen a second later. Older versions ignored this syntax error. Just open the .fdf file and delete those two characters.

Problem 5: Ability levels above 4 read as empty

Credit for this one goes to Arakunido, who found it on his own map and posted it in this thread.

An ability SLK only has per-level columns for levels 1 to 4 (DataA1 to DataA4, Cool1 to Cool4, and so on). Up to 2.0.4, any level above 4 used the level 4 values. The SLKs that ship with 3.0.0 have columns for levels 5 and 6, so a map SLK that stops at level 4 now gives empty values from level 5 up. Every per-level field of that ability reads as empty or 0.

What that looks like depends on the ability:
  • Arakunido's map: every NPC has a spell resistance ability built on Elune's Grace, with 100 levels. From level 5 up, "piercing damage taken" and "damage multiplier" both became 0, so physical attacks did 0 damage.
  • my map: hero skills vanished from the command buttons after loading a save or repicking. Only skills at level 4 or lower showed up. They are Channel based, and Channel keeps its Options field (the one with the Visible flag) in DataC, which is per level. From level 5 up it read as 0 and the skill became invisible.
  • The same bug in reverse: our learn-skill slots, based on Attribute Bonus, got buttons at levels 5 and 6, while the one at level 3 did not.
  • Any other per-level value can be hit the same way, including cooldown, range and mana cost.
It is easy to misread. In our map, learning a skill by hand set it back to level 1, it showed up again, and everything pointed at the script instead of the data.

The Fix: In Units\AbilityData.slk, add level 5 and level 6 columns to every per-level field, each a copy of that row's level 4 value. These are the fields:

Code: Select all

Area  BuffID  Cast  Cool  Cost  DataA  DataB  DataC  DataD  DataE
DataF  DataG  DataH  DataI  Dur  EfctID  HeroDur  Rng  UnitID  targs
That is 20 fields, so 40 new columns. Levels above 4 then get the same values they had on 2.0.4. We confirmed it on our map with skills at levels 5 and 6: every skill shows up again, including after repick and switching characters.

If you add the columns by hand, the note about sticky X and Y below applies here too.

The Automated Fix Script

On the end of thread, open the spoiler, it has the code that runs on plain Python (2.7 or 3.x) with no external dependencies and automatically applies all five of these fixes.

Code: Select all

python slkfix.py <map folder>            // Dry run: reports issues but writes nothing
python slkfix.py <map folder> --apply    // Fixes files in place (creates .bak backups)
python slkfix.py <map folder> --apply --out <folder> // Outputs fixed files to a new folder
Point the script at your extracted map root (the folder containing Units, UI, war3map.j) or directly at the Units folder. Use any MPQ editor (like Ladik's or MPQMaster) to inject the fixed files back into your map. Make sure you retain the original file names, re-add what changed, and don't forget to include the two newly generated Units\*Skin.txt files.

A crucial note for manual SLK editors:
In SLK formatting, X and Y coordinates are "sticky". If a C record skips a coordinate, it inherits the previous one. If you delete a column manually without accounting for this, all subsequent cells will shift and completely break your map. The script handles this safely by resolving sticky values first, rewriting explicit coordinates, and fixing the column count in the B; record.

If your map still crashes after all this, post your War3Log.txt below and let me know how far your custom trace file got!
Spoiler:

Code: Select all

#!/usr/bin/env python
# -*- coding: utf-8 -*-
r"""slkfix.py - make an old "SLK mode" map load again on Warcraft III 3.0.0 (build 24268+).

Patch 3.0.0 breaks maps made with KKWE or YDWE, and any map that overrides Units\*.slk
instead of shipping war3map.w3u. None of this is a bug in your map. 3.0.0 stopped accepting
data that older versions accepted.

This script fixes five data problems in that data.

  1. The `file` column in Units\UnitUI.slk and Units\ItemData.slk  -> THE crash.
     Since 1.32 the art fields live in Units\*Skin.txt, and 3.0.0's own UnitUI.slk has no
     `file` column at all. Older versions ignored the extra column. 3.0.0 reads the field,
     gets -1, and uses it as an index. The game dies on the first unit created in the match,
     with any unit, even a base game footman, and nothing in the log.
     Fix: move every model path into Units\UnitSkin.txt / Units\ItemSkin.txt and drop the
     column. A .txt profile is merged with the game data, a .slk replaces the whole table,
     so listing only your own units is safe.

  2. Numbers stored as text: walk="280.", scale="1.", shadowW="140.".
     NOT a crash. This was a suspect before the `file` column turned up, and it is not the
     cause: the map this came from still ships 236 of these in UnitUI.slk and loads fine on
     3.0.0.

  3. Incomplete button positions: `Buttonpos=,2` instead of `0,2`, `Researchbuttonpos=1`
     instead of `1,0`, or the key missing entirely.

  4. A stray `*/` in a .fdf with no `/*` anywhere. 3.0.0 rejects the token and closes the
     game on the loading screen.

  5. Ability levels above 4 read empty values. Found by arakunido on wc3edit.
     An ability SLK only has per-level columns for levels 1 to 4 (DataA1..DataA4,
     Cool1..Cool4, ...). Up to 2.0.4 any level above 4 used the level 4 values. 3.0.0's own
     SLKs have columns for levels 5 and 6, so a map SLK that stops at 4 reads empty from
     level 5 up. Every per-level field of that ability reads as empty or 0: damage values,
     cooldown, range, and flags such as the Visible option of Channel abilities, which makes
     hero skills vanish from the command buttons.
     Fix: in Units\AbilityData.slk, add level 5 and level 6 columns to the 20 per-level
     fields, each a copy of that row's level 4 value.

USAGE

    python slkfix.py <map folder>              # report only, changes nothing
    python slkfix.py <map folder> --apply      # fix in place (writes .bak files)
    python slkfix.py <map folder> --apply --out <other folder>

<map folder> is the ROOT of your extracted map (the folder that contains `Units`, `UI`,
`war3map.j`, ...). You can also point it straight at the `Units` folder.

Extract and repack with any MPQ editor (Ladik's MPQ Editor, MPQMaster, ...). Keep the
original file names and (re)add the files you changed.

Python 2.7 or 3.x, no dependencies.

Public domain. Do whatever you want with it.
"""
from __future__ import print_function

import io
import os
import re
import sys


# ---------------------------------------------------------------- SLK reading and writing

def read_lines(path):
    raw = io.open(path, 'rb').read().decode('utf-8', 'surrogateescape')
    return raw.replace('\r\n', '\n').split('\n'), ('\r\n' in raw)


def write_lines(path, lines, crlf):
    sep = '\r\n' if crlf else '\n'
    io.open(path, 'wb').write(sep.join(lines).encode('utf-8', 'surrogateescape'))


def cells(lines):
    """Yield (line index, x, y, raw K value) for every C record.

    X and Y are sticky in SLK: a record that omits one keeps the previous value. Getting
    this wrong is the classic way to corrupt a file, so it is resolved here once.
    """
    cur_x = cur_y = None
    for i, line in enumerate(lines):
        if not line.startswith('C;'):
            continue
        x = y = k = None
        for field in line.split(';')[1:]:
            if field[:1] == 'X' and field[1:2].isdigit():
                x = int(re.match(r'X(\d+)', field).group(1))
            elif field[:1] == 'Y' and field[1:2].isdigit():
                y = int(re.match(r'Y(\d+)', field).group(1))
            elif field[:1] == 'K':
                k = field[1:]
        if x is not None:
            cur_x = x
        if y is not None:
            cur_y = y
        yield i, cur_x, cur_y, k


def header(lines):
    """{column index: column name} taken from row 1."""
    out = {}
    for _, x, y, k in cells(lines):
        if y == 1 and k:
            out[x] = k.strip('"')
    return out


def table(lines):
    """{row: {column name: value}} for rows 2..n."""
    cols = header(lines)
    rows = {}
    for _, x, y, k in cells(lines):
        if y is None or y < 2 or k is None:
            continue
        rows.setdefault(y, {})[cols.get(x, str(x))] = k.strip('"')
    return rows, cols


def drop_columns(lines, crlf, drop_names):
    """Rewrite the SLK without the named columns, renumbering what is left.

    Every surviving cell is written with an explicit X and Y so the result never depends on
    the sticky state of the line before it.
    """
    cols = header(lines)
    keep = {}
    n = 0
    for x in sorted(cols):
        if cols[x].lower() not in drop_names:
            n += 1
            keep[x] = n
    body = []
    for i, x, y, k in cells(lines):
        if x not in keep or k is None:
            continue
        body.append((i, 'C;X%d;Y%d;K%s' % (keep[x], y, k)))
    result = []
    body_at = dict(body)
    for i, line in enumerate(lines):
        if line.startswith('C;'):
            if i in body_at:
                result.append(body_at[i])
        else:
            result.append(line)
    for i, line in enumerate(result):
        if line.startswith('B;'):
            result[i] = re.sub(r'X\d+', 'X%d' % n, line, count=1)
            break
    return result, n


# ---------------------------------------------------------------- 1. models -> *Skin.txt

def read_profile(path):
    """[CODE] / key=value profile file -> {CODE: {lowercase key: value}} keeping key order."""
    data, order, current = {}, [], None
    if not os.path.exists(path):
        return data, order
    raw = io.open(path, 'rb').read().decode('utf-8', 'surrogateescape')
    for line in raw.replace('\r\n', '\n').split('\n'):
        s = line.strip()
        if s.startswith('[') and s.endswith(']'):
            current = s[1:-1]
            if current not in data:
                data[current] = {}
                order.append(current)
        elif current and '=' in s and not s.startswith('//'):
            key, _, value = s.partition('=')
            data[current][key.strip()] = value
    return data, order


def write_profile(path, data, order, banner):
    lines = list(banner) + ['']
    for code in order:
        lines.append('[%s]' % code)
        for key in sorted(data[code]):
            lines.append('%s=%s' % (key, data[code][key]))
        lines.append('')
    io.open(path, 'wb').write('\r\n'.join(lines).encode('utf-8', 'surrogateescape'))


def move_models(units_dir, slk_name, id_column, skin_name, apply_it, out_dir, log):
    slk_path = find_file(units_dir, slk_name)
    if not slk_path:
        log('  %-14s not in this map, skipping' % slk_name)
        return 0
    lines, crlf = read_lines(slk_path)
    cols = header(lines)
    if 'file' not in [c.lower() for c in cols.values()]:
        log('  %-14s already has no `file` column' % slk_name)
        return 0
    rows, _ = table(lines)
    models = {}
    for _, row in rows.items():
        code = row.get(id_column) or row.get(id_column.lower())
        path = row.get('file')
        if code and path:
            models[code] = path
    log('  %-14s %d models in the `file` column' % (slk_name, len(models)))
    if not apply_it:
        return len(models)

    skin_path = os.path.join(out_dir, skin_name)
    data, order = read_profile(find_file(units_dir, skin_name) or skin_path)
    for code in sorted(models):
        if code not in data:
            data[code] = {}
            order.append(code)
        data[code]['file'] = models[code]
    write_profile(skin_path, data, order, [
        '// Model paths moved out of %s by slkfix.py.' % slk_name,
        '// Warcraft III 3.0.0 crashes on the `file` column in that SLK; this is where the',
        '// current engine reads the model from. Profile .txt files are merged, not replaced.',
    ])
    new_lines, kept = drop_columns(lines, crlf, set(['file']))
    target = os.path.join(out_dir, os.path.basename(slk_path))
    backup(slk_path, target, apply_it)
    write_lines(target, new_lines, crlf)
    log('  %-14s -> %s (%d entries), SLK rewritten with %d columns'
        % (slk_name, skin_name, len(models), kept))
    return len(models)


# ---------------------------------------------------------------- 2. numbers as text

NUMBER = re.compile(r'^-?\d+\.?\d*$')
TRAILING_DOT = re.compile(r'^(-?\d+)\.$')


def fix_numbers(units_dir, apply_it, out_dir, log):
    total = 0
    for name in sorted(os.listdir(units_dir)):
        if not name.lower().endswith('.slk'):
            continue
        # step 1 may already have rewritten this file into out_dir; work on that copy, or the
        # `file` column would come back
        path = find_file(out_dir, name) or os.path.join(units_dir, name)
        lines, crlf = read_lines(path)
        cols = header(lines)
        rows, _ = table(lines)

        # A column is numeric when almost everything in it already is a number. That is more
        # reliable than a hand-written list of column names, which never survives a map that
        # invented its own.
        numeric = set()
        for x, col in cols.items():
            values = [r.get(col) for r in rows.values() if r.get(col) not in (None, '')]
            if len(values) < 4:
                continue
            good = sum(1 for v in values if NUMBER.match(v))
            if good >= len(values) * 0.9:
                numeric.add(col)

        changed = 0
        out = []
        for i, line in enumerate(lines):
            out.append(line)
        for i, x, y, k in cells(lines):
            if y is None or y < 2 or k is None or not k.startswith('"'):
                continue
            col = cols.get(x)
            if col not in numeric:
                continue
            value = k.strip('"')
            m = TRAILING_DOT.match(value)
            if m:
                value = m.group(1)
            elif not NUMBER.match(value):
                continue
            out[i] = re.sub(r'K"[^"]*"', 'K' + value, out[i], count=1)
            changed += 1
        if changed:
            total += changed
            log('  %-22s %d numeric cells were quoted text' % (name, changed))
            if apply_it:
                target = os.path.join(out_dir, name)
                backup(path, target, apply_it)
                write_lines(target, out, crlf)
    if not total:
        log('  no numbers stored as text')
    return total


# ---------------------------------------------------------------- 3. button positions

POSKEYS = ('buttonpos', 'researchbuttonpos', 'unbuttonpos')


def fix_buttonpos(units_dir, apply_it, out_dir, log):
    total = 0
    for name in sorted(os.listdir(units_dir)):
        if not name.lower().endswith('.txt'):
            continue
        path = find_file(out_dir, name) or os.path.join(units_dir, name)
        raw = io.open(path, 'rb').read().decode('utf-8', 'surrogateescape')
        crlf = '\r\n' in raw
        lines = raw.replace('\r\n', '\n').split('\n')
        changed = 0
        for i, line in enumerate(lines):
            s = line.strip()
            if '=' not in s or s.startswith('//'):
                continue
            key, _, value = s.partition('=')
            if key.strip().lower() not in POSKEYS:
                continue
            value = value.strip()
            if ',' not in value:
                if value == '':
                    new = '0,0'
                else:
                    new = value + ',0'          # a lone number is the X
            else:
                a, _, b = value.partition(',')
                new = (a.strip() or '0') + ',' + (b.strip() or '0')
            if new != value:
                lines[i] = key + '=' + new
                changed += 1
        if changed:
            total += changed
            log('  %-26s %d half-written button positions' % (name, changed))
            if apply_it:
                target = os.path.join(out_dir, name)
                backup(path, target, apply_it)
                io.open(target, 'wb').write(
                    ('\r\n' if crlf else '\n').join(lines).encode('utf-8', 'surrogateescape'))
    if not total:
        log('  no half-written button positions')
    return total


# ---------------------------------------------------------------- 4. stray */ in a .fdf

def fix_fdf(root, apply_it, log):
    total = 0
    for base, _, names in os.walk(root):
        for name in names:
            if not name.lower().endswith('.fdf'):
                continue
            path = os.path.join(base, name)
            raw = io.open(path, 'rb').read().decode('utf-8', 'surrogateescape')
            if '*/' not in raw or '/*' in raw:
                continue
            n = raw.count('*/')
            total += n
            log('  %-26s %d stray */ with no /* anywhere' % (name, n))
            if apply_it:
                io.open(path + '.bak', 'wb').write(raw.encode('utf-8', 'surrogateescape'))
                io.open(path, 'wb').write(raw.replace('*/', '').encode('utf-8', 'surrogateescape'))
    if not total:
        log('  no stray comment terminators')
    return total


# ---------------------------------------------------------------- 5. ability levels above 4

# The per-level fields of Units\AbilityData.slk, as listed by arakunido.
LEVEL_FIELDS = ['Area', 'BuffID', 'Cast', 'Cool', 'Cost',
                'DataA', 'DataB', 'DataC', 'DataD', 'DataE', 'DataF', 'DataG', 'DataH', 'DataI',
                'Dur', 'EfctID', 'HeroDur', 'Rng', 'UnitID', 'targs']
NEW_LEVELS = (5, 6)


def add_levels(units_dir, apply_it, out_dir, log):
    name = 'AbilityData.slk'
    path = find_file(out_dir, name) or find_file(units_dir, name)
    if not path:
        log('  %-22s not in this map, skipping' % name)
        return 0
    base = os.path.basename(path)
    lines, crlf = read_lines(path)
    cols = header(lines)
    by_name = dict((v, x) for x, v in cols.items())
    if any(('%s5' % f) in by_name for f in LEVEL_FIELDS):
        log('  %-22s already has level 5 columns' % base)
        return 0

    # new columns go after the last one; each remembers which level 4 column it copies
    last = max(cols) if cols else 0
    plan = []
    for f in LEVEL_FIELDS:
        x4 = by_name.get('%s4' % f)
        if x4 is None:
            continue
        new = []
        for level in NEW_LEVELS:
            last += 1
            new.append((last, '%s%d' % (f, level)))
        plan.append((x4, new))
    if not plan:
        log('  %-22s has no per-level columns, skipping' % base)
        return 0
    added = len(plan) * len(NEW_LEVELS)
    log('  %-22s %d per-level fields stop at level 4, %d columns to add' % (base, len(plan), added))
    if not apply_it:
        return added

    resolved = list(cells(lines))
    level4 = {}
    for _, x, y, k in resolved:
        if k is not None and y is not None and y > 1:
            level4[(y, x)] = k

    def new_cells(y):
        out = []
        for x4, new in plan:
            for xn, col_name in new:
                if y == 1:
                    out.append('C;X%d;Y1;K"%s"' % (xn, col_name))
                else:
                    k = level4.get((y, x4))
                    if k is not None:
                        out.append('C;X%d;Y%d;K%s' % (xn, y, k))
        return out

    # every cell is rewritten with an explicit X and Y, and each row gets its new cells right
    # after its last one, so nothing depends on the sticky state of the record before it
    at = dict((i, (x, y, k)) for i, x, y, k in resolved)
    result = []
    row = None
    for i, line in enumerate(lines):
        if i in at:
            x, y, k = at[i]
            if row is not None and y != row:
                result.extend(new_cells(row))
            row = y
            if k is not None:
                result.append('C;X%d;Y%d;K%s' % (x, y, k))
        else:
            if line.startswith('E') and row is not None:
                result.extend(new_cells(row))
                row = None
            result.append(line)
    if row is not None:
        result.extend(new_cells(row))
    for i, line in enumerate(result):
        if line.startswith('B;'):
            result[i] = re.sub(r'X\d+', 'X%d' % last, line, count=1)
            break

    target = os.path.join(out_dir, base)
    backup(path, target, apply_it)
    write_lines(target, result, crlf)
    log('  %-22s %d columns added as copies of level 4, %d columns now' % (base, added, last))
    return added


# ---------------------------------------------------------------- helpers

def find_file(folder, name):
    """Case-insensitive lookup: MPQ contents come out in whatever case the author used."""
    if not os.path.isdir(folder):
        return None
    for entry in os.listdir(folder):
        if entry.lower() == name.lower():
            return os.path.join(folder, entry)
    return None


IN_PLACE = True


def backup(source, target, apply_it):
    """Keep a .bak only when we are overwriting the map's own file."""
    if not (apply_it and IN_PLACE):
        return
    if os.path.abspath(source) == os.path.abspath(target) and not os.path.exists(source + '.bak'):
        io.open(source + '.bak', 'wb').write(io.open(source, 'rb').read())


def main():
    args = [a for a in sys.argv[1:] if not a.startswith('--')]
    apply_it = '--apply' in sys.argv
    out_dir = None
    for a in sys.argv[1:]:
        if a.startswith('--out='):
            out_dir = a[6:]
    if '--out' in sys.argv:
        out_dir = sys.argv[sys.argv.index('--out') + 1]
    if not args:
        print(__doc__)
        return 1

    root = args[0]
    units = root if os.path.basename(root.rstrip('\\/')).lower() == 'units' \
        else (find_file(root, 'Units') or root)
    if not os.path.isdir(units):
        print('could not find the Units folder under %r' % root)
        return 1
    global IN_PLACE
    out = out_dir or units
    IN_PLACE = os.path.abspath(out) == os.path.abspath(units)
    if apply_it and not IN_PLACE:
        # a separate output folder starts as a copy, so what comes out is a complete Units
        # folder you can drop straight back into the MPQ
        if not os.path.isdir(out):
            os.makedirs(out)
        for entry in os.listdir(units):
            src = os.path.join(units, entry)
            if os.path.isfile(src):
                io.open(os.path.join(out, entry), 'wb').write(io.open(src, 'rb').read())

    def log(msg):
        print(msg)

    print('map root : %s' % os.path.abspath(root))
    print('Units    : %s' % os.path.abspath(units))
    print('mode     : %s' % ('APPLYING CHANGES' if apply_it else 'report only (add --apply to write)'))
    print('')

    print('1. model paths in the `file` column  (this is the one that crashes 3.0.0)')
    n1 = move_models(units, 'UnitUI.slk', 'unitUIID', 'UnitSkin.txt', apply_it, out, log)
    n1 += move_models(units, 'ItemData.slk', 'itemID', 'ItemSkin.txt', apply_it, out, log)
    print('')
    print('2. numbers stored as text  (cleanup, not a crash)')
    n2 = fix_numbers(units, apply_it, out, log)
    print('')
    print('3. half-written button positions')
    n3 = fix_buttonpos(units, apply_it, out, log)
    print('')
    print('4. stray */ in .fdf files')
    n4 = fix_fdf(root, apply_it, log)
    print('')
    print('5. ability levels above 4  (found by arakunido)')
    n5 = add_levels(units, apply_it, out, log)
    print('')
    print('models moved %d | numbers %d | button positions %d | fdf %d | level columns %d'
          % (n1, n2, n3, n4, n5))
    if not apply_it and (n1 or n2 or n3 or n4 or n5):
        print('nothing was written -- run again with --apply')
    return 0


if __name__ == '__main__':
    sys.exit(main())
Last edited by devoltz on September 13th, 2026, 12:12 am, edited 1 time in total.
Reason: Adding Arakunido's abilities fix.
Arakunido
Cheater
Posts: 186
Joined: February 7th, 2013, 5:04 am
Title: Skid
Been thanked: 1 time

Re: [GUIDE] Fixing SLK Maps That Crash on Reforged 3.0.0 (build 24268)

Post by Arakunido »

Another thing to look out for if your map breaks and uses the SLK format: abilities above level 4 break

The tests were done on my map, so here is what to look for:


Cause: an SLK stores ability data for levels 1–4 only (DataA1…DataA4, Cool1…Cool4, and so on).

Up to 2.0.4:
any level above 4 used level 4's values.
3.0.0: the game's own SLKs have columns for levels 5 and 6. A map SLK that stops at level 4 now gives empty values from level 5 up, so every per-level field of that ability reads as empty or zero.
In our map, every NPC has a "spell resistance" ability built on the Elune's Grace base (AIdd), with 100 levels, and its level grows with the waves. Two of its values are "piercing damage taken" and "damage multiplier". From level 5 up both became 0, so physical attacks did 0 damage.

Code: Select all

before wave 3
Ability level: ≤ 4
Damage: 128 → 128
HP lost: 267

wave 3
Ability level: 8
Damage: 49 → 0
HP lost: 0
Potential fix:

in Units\AbilityData.slk, add level-5 and level-6 columns to every per-level field, each a copy of that row's level-4 value. The fields are Area, BuffID, Cast, Cool, Cost, DataA–DataI, Dur, EfctID, HeroDur, Rng, UnitID and targs, which is 40 new columns. Levels above 4 then get the same values they had on 2.0.4.
Best regards,

Arakunido.
User avatar
devoltz
Co-Admin
Posts: 3188
Joined: March 23rd, 2016, 8:06 pm
Has thanked: 16 times
Been thanked: 73 times

Re: [GUIDE] Fixing SLK Maps That Crash on Reforged 3.0.0 (build 24268)

Post by devoltz »

Arakunido wrote: Yesterday, 11:49 pm Another thing to look out for if your map breaks and uses the SLK format: abilities above level 4 break

The tests were done on my map, so here is what to look for:


Cause: an SLK stores ability data for levels 1–4 only (DataA1…DataA4, Cool1…Cool4, and so on).

Up to 2.0.4:
any level above 4 used level 4's values.
3.0.0: the game's own SLKs have columns for levels 5 and 6. A map SLK that stops at level 4 now gives empty values from level 5 up, so every per-level field of that ability reads as empty or zero.
In our map, every NPC has a "spell resistance" ability built on the Elune's Grace base (AIdd), with 100 levels, and its level grows with the waves. Two of its values are "piercing damage taken" and "damage multiplier". From level 5 up both became 0, so physical attacks did 0 damage.

Code: Select all

before wave 3
Ability level: ≤ 4
Damage: 128 → 128
HP lost: 267

wave 3
Ability level: 8
Damage: 49 → 0
HP lost: 0
Potential fix:

in Units\AbilityData.slk, add level-5 and level-6 columns to every per-level field, each a copy of that row's level-4 value. The fields are Area, BuffID, Cast, Cool, Cost, DataA–DataI, Dur, EfctID, HeroDur, Rng, UnitID and targs, which is 40 new columns. Levels above 4 then get the same values they had on 2.0.4.
Thanks, this was exactly it for me too.

My map showed it in a different way, so here it is in case someone else runs into the same thing.

Hero skills vanished from the command buttons after loading a save or repicking. Only skills at level 4 or lower showed up. The skills were on the hero, at the right level, you just could not see or cast them.

Those skills are based on Channel. Channel keeps its Options field, the one with the Visible flag, in DataC, and that is a per-level field. From level 5 up it read as 0, so the skill became invisible.

It also did the opposite to our learn-skill slots, which are based on Attribute Bonus. At levels 5 and 6 they suddenly got buttons, while the one at level 3 did not.

It fooled me for a while bc in my map, learning a skill by hand set it back to level 1, it showed up again, and everything pointed at the script instead of the data.

I added the level 5 and 6 columns the way you described, copying level 4. Every skill shows up now, including after repick and switching characters. Tested with skills at levels 5 and 6.

I added it to the first post as Problem 5 with credit to you, and the script does it too now.