Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -413,7 +413,7 @@ public void fastHashMapAgainstTheStandardLibrary() {
*/
@Test
public void fastHashMapAgainstTheStandardLibraryLua() throws IOException {
test().withStdLib().testLua(true)
test().withStdLib().testLua(true).executeProg()
.lines(withStandardLibrary(program(fastHashMap(), INT_INSTANCE, USE_WITH_COLLISION)));
assertSpecialisedClassesAllocateTheirFields(
Files.toString(new File("test-output/lua/FastHashMapTests_fastHashMapAgainstTheStandardLibraryLua.lua"),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,7 @@
* are only comparable where the library is defined - a string of whole characters - so that is what
* is compared, and it is enough: the arithmetic is the same for every input, only the bytes differ.
*/
public class Wc3StringHashTest {
public class Wc3StringHashTest extends WurstScriptTest {

private static void agrees(String text) throws UnsupportedEncodingException {
assertEquals(Wc3StringHash.hash(ILconstString.fromText(text).getVal()),
Expand DownExpand Up@@ -87,4 +87,75 @@ public void everyContinuationByteHashesApart() {
}
assertEquals(hashes.size(), 64, "all 64 continuation bytes should hash apart");
}

/**
* The Lua test runtime carries a second implementation of this hash, because a program running
* there computes it too. Two transcriptions of one algorithm drift, so this runs the inputs
* through both and compares — asserting the Java side against written-down numbers would stay
* green if only the Lua side changed, which is the case worth catching.
*/
@Test
public void agreesWithTheLuaRuntimeImplementation() throws Exception {
String[] inputs = {"abc", "Hello World", "Units\\Human\\Footman.mdx", "ä",
"abcdefghijklmnop", "", "MIXED/Case\\Path", "日本語"};

for (String input : inputs) {
assertEquals(luaHashOf(input), Wc3StringHash.hash(ILconstString.fromText(input).getVal()),
"hash of " + input);
}
}

/** Runs the shim's StringHash on one input, as a program on that target would. */
private int luaHashOf(String text) throws Exception {
// Escaped byte by byte, so what the shim hashes is what the Java side was handed rather
// than whatever the command line did to it.
StringBuilder literal = new StringBuilder();
for (byte b : text.getBytes(java.nio.charset.StandardCharsets.UTF_8)) {
literal.append("\\").append(b & 0xFF);
}
String script = "dofile('src/test/resources/luaruntime/wc3shim.lua') "
+ "print(StringHash('" + literal + "'))";
String lua;
try {
lua = getLuaExecutable();
} catch (IllegalStateException e) {
// Same handling as the normal execution path: a host without a working interpreter
// skips visibly rather than failing the class, since nothing here is being tested.
throw new org.testng.SkipException("Skipped the Lua half of the hash parity check: " + e.getMessage());
}
Process p = new ProcessBuilder(lua, "-e", script)
.redirectErrorStream(true)
.start();
// Drained on a thread of its own and waited for with a deadline. Reading to the end first
// waits for the process to close the stream, which a hung one never does - the timeout
// below would then be reached only after the hang had already stopped the suite.
StringBuilder output = new StringBuilder();
Thread drain = new Thread(() -> {
try (java.io.BufferedReader r = new java.io.BufferedReader(
new java.io.InputStreamReader(p.getInputStream(), java.nio.charset.StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
output.append(line).append('\n');
}
} catch (java.io.IOException e) {
// The process was killed underneath the read; the timeout below reports it.
}
});
drain.setDaemon(true);
drain.start();

if (!p.waitFor(30, java.util.concurrent.TimeUnit.SECONDS)) {
p.destroyForcibly();
drain.join(5_000);
throw new AssertionError("lua did not finish hashing \"" + text + "\" within 30s");
}
drain.join(5_000);

String out = output.toString().trim();
try {
return Integer.parseInt(out);
} catch (NumberFormatException e) {
throw new AssertionError("lua did not return a hash for \"" + text + "\": " + out);
}
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -556,6 +556,11 @@ private void translateAndTestLua(String name, boolean executeProg, WurstGui gui,
}
}
chunk.append("dofile('").append(luaFile.getPath().replace('\\', '/')).append("');");
// Success is read off stdout, so testSuccess has to print. A program with no
// standard library gets a generated fallback which does; one with the library gets
// the library's own, which is empty - so without this, a test on that target can
// only ever be reported as not having succeeded, whatever it did.
chunk.append("testSuccess = function() print('testSuccess') os.exit() end;");
chunk.append("main()");
String[] args = {
luaExecutable,
Expand DownExpand Up@@ -701,7 +706,7 @@ private Thread collectStreamAsync(InputStream stream, StringBuilder out,
return t;
}

private String getLuaExecutable() {
protected String getLuaExecutable() {
if (resolvedLuaExecutable != null) {
return resolvedLuaExecutable;
}
Expand Down
102 changes: 102 additions & 0 deletions de.peeeq.wurstscript/src/test/resources/luaruntime/wc3shim.lua
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,108 @@ end
function I2S(i) return tostring(math.floor(i)) end
function S2I(s) return math.floor(tonumber(s) or 0) end

-- StringHash, over bytes, as the game and the interpreter both compute it. Bob Jenkins'
-- lookup2, with the same normalisation: ascii letters upper-cased and a forward slash read as
-- a backslash. Kept in step with Wc3StringHash on the Java side.
local function mix(a, b, c)
local M = 0xFFFFFFFF
a = (a - b - c) & M; a = a ~ (c >> 13)
b = (b - c - a) & M; b = b ~ ((a << 8) & M)
c = (c - a - b) & M; c = c ~ (b >> 13)
a = (a - b - c) & M; a = a ~ (c >> 12)
b = (b - c - a) & M; b = b ~ ((a << 16) & M)
c = (c - a - b) & M; c = c ~ (b >> 5)
a = (a - b - c) & M; a = a ~ (c >> 3)
b = (b - c - a) & M; b = b ~ ((a << 10) & M)
c = (c - a - b) & M; c = c ~ (b >> 15)
return a, b, c
end

function StringHash(s)
if s == nil or #s == 0 then
return 0
end
local bytes = {}
for i = 1, #s do
local v = string.byte(s, i)
if v >= 97 and v <= 122 then
v = v - 32
elseif v == 47 then
v = 92
end
bytes[i] = v
end
local a, b, c = 0x9e3779b9, 0x9e3779b9, 0
local len = #bytes
local i = 1
local M = 0xFFFFFFFF
while len >= 12 do
a = (a + bytes[i] + (bytes[i+1] << 8) + (bytes[i+2] << 16) + (bytes[i+3] << 24)) & M
b = (b + bytes[i+4] + (bytes[i+5] << 8) + (bytes[i+6] << 16) + (bytes[i+7] << 24)) & M
c = (c + bytes[i+8] + (bytes[i+9] << 8) + (bytes[i+10] << 16) + (bytes[i+11] << 24)) & M
a, b, c = mix(a, b, c)
i = i + 12
len = len - 12
end
c = (c + #bytes) & M
-- The low byte of c holds the length, so the tail starts at the second.
if len >= 11 then c = (c + (bytes[i+10] << 24)) & M end
if len >= 10 then c = (c + (bytes[i+9] << 16)) & M end
if len >= 9 then c = (c + (bytes[i+8] << 8)) & M end
if len >= 8 then b = (b + (bytes[i+7] << 24)) & M end
if len >= 7 then b = (b + (bytes[i+6] << 16)) & M end
if len >= 6 then b = (b + (bytes[i+5] << 8)) & M end
if len >= 5 then b = (b + bytes[i+4]) & M end
if len >= 4 then a = (a + (bytes[i+3] << 24)) & M end
if len >= 3 then a = (a + (bytes[i+2] << 16)) & M end
if len >= 2 then a = (a + (bytes[i+1] << 8)) & M end
if len >= 1 then a = (a + bytes[i]) & M end
a, b, c = mix(a, b, c)
-- The game returns a signed 32 bit integer.
if c >= 0x80000000 then
c = c - 0x100000000
end
return c
end

-- Only ascii letters change case: the bytes of a multibyte character are not letters, and
-- folding one rewrites the character. Same rule as the interpreter's StringCase.
function StringCase(s, upperCase)
local out = {}
for i = 1, #s do
local v = string.byte(s, i)
if upperCase and v >= 97 and v <= 122 then
v = v - 32
elseif not upperCase and v >= 65 and v <= 90 then
v = v + 32
end
out[i] = string.char(v)
end
return table.concat(out)
end

-- Locations are a plain pair; nothing in a test reads terrain from one.
function Location(x, y) return { x = x, y = y } end
function GetLocationX(loc) return loc.x end
function GetLocationY(loc) return loc.y end
function MoveLocation(loc, x, y) loc.x = x loc.y = y end
function RemoveLocation(loc) end

-- Timers hold what they were started with and never fire: a test drives its own program rather
-- than waiting on game time, and a package which starts a timer at init only needs the call to
-- succeed.
function TimerStart(t, timeout, periodic, handler)
t.timeout = timeout
t.periodic = periodic
t.handler = handler
end
function TimerGetElapsed(t) return 0.0 end
function TimerGetRemaining(t) return t.timeout or 0.0 end
function TimerGetTimeout(t) return t.timeout or 0.0 end
function PauseTimer(t) end
function ResumeTimer(t) end
function DestroyTimer(t) end

-- Reforged player layout: 24 playable slots, neutrals at 24..27, 28 total.
function GetBJMaxPlayers() return 24 end
function GetBJMaxPlayerSlots() return 28 end
Expand Down
Loading