Add Map Exchange String parser

Add readMapExchangeString to lib/factorio which parses and decodes the
data in Map Exchange into a format suitable for giving to the Factorio
server command line.
This commit is contained in:
Hornwitser committed 2021-05-20 21:51:51 +02:00
1 parent b352e89b48
commit 2eadc3ea10
4 files changed
+497 -1

No files matched your search

-1
View File
@@ -31,7 +31,6 @@ dist/
node_modules
Factorio*
factorio*
/factorio
/instances
/sharedMods
+393
View File
@@ -0,0 +1,393 @@
"use strict";
const zlib = require("zlib");
class MapReaderState {
constructor(buf) {
this.pos = 0;
this.buf = buf;
this.last_position = { x: 0, y: 0 };
}
}
function readUInt8(state) {
let value = state.buf.readUInt8(state.pos);
state.pos += 1;
return value;
}
function readBool(state) {
let value = readUInt8(state) !== 0;
return value;
}
function readInt16(state) {
let value = state.buf.readInt16LE(state.pos);
state.pos += 2;
return value;
}
function readUInt16(state) {
let value = state.buf.readUInt16LE(state.pos);
state.pos += 2;
return value;
}
function readInt32(state) {
let value = state.buf.readInt32LE(state.pos);
state.pos += 4;
return value;
}
function readUInt32(state) {
let value = state.buf.readUInt32LE(state.pos);
state.pos += 4;
return value;
}
function readUInt32so(state) {
let value = readUInt8(state);
if (value === 0xff) {
return readUInt32(state);
}
return value;
}
function readFloat(state) {
let value = state.buf.readFloatLE(state.pos);
state.pos += 4;
return value;
}
function readDouble(state) {
let value = state.buf.readDoubleLE(state.pos);
state.pos += 8;
return value;
}
function readString(state) {
let size = readUInt32so(state);
let data = state.buf.slice(state.pos, state.pos + size).toString("utf-8");
state.pos += size;
return data;
}
function readOptional(state, readValue) {
let load = readUInt8(state) !== 0;
if (!load) {
return null;
}
return readValue(state);
}
function readArray(state, readItem) {
let size = readUInt32so(state);
let array = [];
for (let i = 0; i < size; i++) {
let item = readItem(state);
array.push(item);
}
return array;
}
function readDict(state, readKey, readValue) {
let size = readUInt32so(state);
let mapping = new Map();
for (let i = 0; i < size; i++) {
let key = readKey(state);
let value = readValue(state);
mapping.set(key, value);
}
return mapping;
}
function readVersion(state) {
let major = readUInt16(state);
let minor = readUInt16(state);
let patch = readUInt16(state);
let developer = readUInt16(state);
return [major, minor, patch, developer];
}
function readFrequencySizeRichness(state) {
return {
frequency: readFloat(state),
size: readFloat(state),
richness: readFloat(state),
};
}
function readAutoplaceSetting(state) {
return {
treat_missing_as_default: readBool(state),
settings: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
};
}
function readMapPosition(state) {
let x, y;
let x_diff = readInt16(state) / 256;
if (x_diff === 0x7fff / 256) {
x = readInt32(state) / 256;
y = readInt32(state) / 256;
} else {
let y_diff = readInt16(state) / 256;
x = state.last_position.x + x_diff;
y = state.last_position.y + y_diff;
}
state.last_position.x = x;
state.last_position.x = y;
return { x, y };
}
function readBoundingBox(state) {
return {
left_top: readMapPosition(state),
right_bottom: readMapPosition(state),
orientation: {
x: readInt16(state),
y: readInt16(state),
},
};
}
function readCliffSettings(state) {
return {
name: readString(state),
elevation_0: readFloat(state),
elevation_interval: readFloat(state),
richness: readFloat(state),
};
}
function readMapGenSettings(state) {
return {
terrain_segmentation: readFloat(state),
water: readFloat(state),
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
default_enable_all_autoplace_controls: readBool(state),
seed: readUInt32(state),
width: readUInt32(state),
height: readUInt32(state),
area_to_generate_at_start: readBoundingBox(state),
starting_area: readFloat(state),
peaceful_mode: readBool(state),
starting_points: readArray(state, readMapPosition),
property_expression_names: Object.fromEntries(readDict(state, readString, readString)),
cliff_settings: readCliffSettings(state),
};
}
function readPollution(state) {
return {
enabled: readOptional(state, readBool),
diffusion_ratio: readOptional(state, readDouble),
min_to_diffuse: readOptional(state, readDouble),
ageing: readOptional(state, readDouble),
expected_max_per_chunk: readOptional(state, readDouble),
min_to_show_per_chunk: readOptional(state, readDouble),
min_pollution_to_damage_trees: readOptional(state, readDouble),
pollution_with_max_forest_damage: readOptional(state, readDouble),
pollution_per_tree_damage: readOptional(state, readDouble),
pollution_restored_per_tree_damage: readOptional(state, readDouble),
max_pollution_to_restore_trees: readOptional(state, readDouble),
enemy_attack_pollution_consumption_modifier: readOptional(state, readDouble),
};
}
function readSteeringValue(state) {
return {
radius: readOptional(state, readDouble),
separation_factor: readOptional(state, readDouble),
separation_force: readOptional(state, readDouble),
force_unit_fuzzy_goto_behavior: readOptional(state, readBool),
};
}
function readSteering(state) {
return {
default: readSteeringValue(state),
moving: readSteeringValue(state),
};
}
function readEnemyEvolution(state) {
return {
enabled: readOptional(state, readBool),
time_factor: readOptional(state, readDouble),
destroy_factor: readOptional(state, readDouble),
pollution_factor: readOptional(state, readDouble),
};
}
function readEnemyExpansion(state) {
return {
enabled: readOptional(state, readBool),
max_expansion_distance: readOptional(state, readUInt32),
friendly_base_influence_radius: readOptional(state, readUInt32),
enemy_building_influence_radius: readOptional(state, readUInt32),
building_coefficient: readOptional(state, readDouble),
other_base_coefficient: readOptional(state, readDouble),
neighbouring_chunk_coefficient: readOptional(state, readDouble),
neighbouring_base_chunk_coefficient: readOptional(state, readDouble),
max_colliding_tiles_coefficient: readOptional(state, readDouble),
settler_group_min_size: readOptional(state, readUInt32),
settler_group_max_size: readOptional(state, readUInt32),
min_expansion_cooldown: readOptional(state, readUInt32),
max_expansion_cooldown: readOptional(state, readUInt32),
};
}
function readUnitGroup(state) {
return {
min_group_gathering_time: readOptional(state, readUInt32),
max_group_gathering_time: readOptional(state, readUInt32),
max_wait_time_for_late_members: readOptional(state, readUInt32),
max_group_radius: readOptional(state, readDouble),
min_group_radius: readOptional(state, readDouble),
max_member_speedup_when_behind: readOptional(state, readDouble),
max_member_slowdown_when_ahead: readOptional(state, readDouble),
max_group_slowdown_factor: readOptional(state, readDouble),
max_group_member_fallback_factor: readOptional(state, readDouble),
member_disown_distance: readOptional(state, readDouble),
tick_tolerance_when_member_arrives: readOptional(state, readUInt32),
max_gathering_unit_groups: readOptional(state, readUInt32),
max_unit_group_size: readOptional(state, readUInt32),
};
}
function readPathFinder(state) {
return {
fwd2bwd_ratio: readOptional(state, readInt32),
goal_pressure_ratio: readOptional(state, readDouble),
use_path_cache: readOptional(state, readBool),
max_steps_worked_per_tick: readOptional(state, readDouble),
max_work_done_per_tick: readOptional(state, readUInt32),
short_cache_size: readOptional(state, readUInt32),
long_cache_size: readOptional(state, readUInt32),
short_cache_min_cacheable_distance: readOptional(state, readDouble),
short_cache_min_algo_steps_to_cache: readOptional(state, readUInt32),
long_cache_min_cacheable_distance: readOptional(state, readDouble),
cache_max_connect_to_cache_steps_multiplier: readOptional(state, readUInt32),
cache_accept_path_start_distance_ratio: readOptional(state, readDouble),
cache_accept_path_end_distance_ratio: readOptional(state, readDouble),
negative_cache_accept_path_start_distance_ratio: readOptional(state, readDouble),
negative_cache_accept_path_end_distance_ratio: readOptional(state, readDouble),
cache_path_start_distance_rating_multiplier: readOptional(state, readDouble),
cache_path_end_distance_rating_multiplier: readOptional(state, readDouble),
stale_enemy_with_same_destination_collision_penalty: readOptional(state, readDouble),
ignore_moving_enemy_collision_distance: readOptional(state, readDouble),
enemy_with_different_destination_collision_penalty: readOptional(state, readDouble),
general_entity_collision_penalty: readOptional(state, readDouble),
general_entity_subsequent_collision_penalty: readOptional(state, readDouble),
extended_collision_penalty: readOptional(state, readDouble),
max_clients_to_accept_any_new_request: readOptional(state, readUInt32),
max_clients_to_accept_short_new_request: readOptional(state, readUInt32),
direct_distance_to_consider_short_request: readOptional(state, readUInt32),
short_request_max_steps: readOptional(state, readUInt32),
short_request_ratio: readOptional(state, readDouble),
min_steps_to_check_path_find_termination: readOptional(state, readUInt32),
start_to_goal_cost_multiplier_to_terminate_path_find: readOptional(state, readDouble),
overload_levels: readOptional(state, (p) => readArray(p, readUInt32)),
overload_multipliers: readOptional(state, (p) => readArray(p, readDouble)),
negative_path_cache_delay_interval: readOptional(state, readUInt32),
};
}
function readDifficultySettings(state) {
return {
recipe_difficulty: readUInt8(state),
technology_difficulty: readUInt8(state),
technology_price_multiplier: readDouble(state),
research_queue_setting: ["always", "after-victory", "never"][readUInt8(state)],
};
}
function readMapSettings(state) {
return {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
};
}
/**
* @typedef {object} module:lib/factorio.MapExchangeResult
* @property {array} version -
* Version of Factorio the string was created with.
* @property {object} map_gen_settings -
* Decoded map generator settings in the format the --map-gen-settings
* command line option to Factorio expect.
* @property {object} map_settings -
* Decoded map settings in the format the --map-settings command line option
* to Factorio expects.
* @property {number} checksum - checksum for the exchange string.
*/
/**
* Parse a Map Exchange String
*
* Reads and decodes the data in the given map exchange string and returns
* data structures that can be fed into the Factorio server when creating a
* save to set the map gen settings and the map settings for the save.
*
* @param {string} exchangeString - Max Exchange String to parse.
* @returns {module:lib/factorio.MapExchangeResult} Parsed result.
* @memberof module:lib/factorio
*/
function readMapExchangeString(exchangeString) {
exchangeString = exchangeString.replace(/[ \t\n\r]+/g, "");
if (!/>>>[0-9a-zA-Z\/+]+={0,3}<<</.test(exchangeString)) {
throw new Error("Not a map exchange string");
}
let buf = Buffer.from(exchangeString.slice(3, -3), "base64");
try {
// eslint-disable-next-line node/no-sync
buf = zlib.inflateSync(buf);
} catch (err) {
if (err.code.startsWith("Z_")) {
throw new Error("Malformed map exchange string: zlib inflate failed");
}
}
let state = new MapReaderState(buf);
let data;
try {
data = {
version: readVersion(state),
unknown: readUInt8(state),
map_gen_settings: readMapGenSettings(state),
map_settings: readMapSettings(state),
checksum: readUInt32(state),
};
} catch (err) {
if (err.code === "ERR_OUT_OF_RANGE") {
throw new Error("Malformed map exchange string: reached end before finishing parsing");
}
throw err;
}
if (state.pos !== buf.length) {
throw new Error("Malformed map exchange string: data after end");
}
return data;
}
module.exports = {
readMapExchangeString,
};
+2
View File
@@ -4,11 +4,13 @@
* @author Hornwitser
*/
"use strict";
const exchange_string = require("./exchange_string");
const export_ = require("./export");
const patch = require("./patch");
const server = require("./server");
module.exports = {
...exchange_string,
...export_,
...patch,
...server,
+102
View File
@@ -0,0 +1,102 @@
"use strict";
const assert = require("assert").strict;
const zlib = require("zlib");
const libFactorio = require("@clusterio/lib/factorio");
let defaultString = `
>>>eNpjZGBkUGQAgwZ7EOZgSc5PzIHxQJgrOb+gILVIN78oFVmYM
7moNCVVNz8TVXFqXmpupW5SYjGKYo7Movw8dBNYi0vy81BFSopSU
4uRRbhLixLzMktz0fUyMF5imubZ0CLHAML/6xkU/v8HYSDrAdAvI
MzA2ABRCRSDAdbknMy0NAYGBUcgdgJJMzIyVousc39YNcWeEaJGz
wHK+AAVOZAEE/GEMfwccEqpwBgmSOYYg8FnJAbE0hKgFVBVHA4IB
kSyBSTJyNj7duuC78cu2DH+Wfnxkm9Sgj2joavIuw9G6+yAkuwgf
zLBiVkzQWAnzCsMMDMf2EOlbtoznj0DAm/sGVlBOkRAhIMFkDjgz
czAKMAHZC3oARIKMgwwp9nBjBFxYEwDg28wnzyGMS7bo/sDGBA2I
MPlQMQJEAG2EO4yRgjTod+B0UEeJiuJUALUb8SA7IYUhA9Pwqw9j
GQ/mkMwIwLZH2giKg5YooELZGEKnHjBDHcNMDwvsMN4DvMdGJlBD
JCqL0AxCA8kAzMKQgs4gIObmQEBgGmjrO/lHgDxa6G4<<<
`;
let testString = `
>>>eNp1UzFs00AUvZ8mbRIEypCBSlCCyFAhOQqBKULxlQUxMCCxgnCc
S2rh2On5LAgMZOjAgISEurQLXSmCBTGwRWKhEkgIJraiMgBiaGmFOiC
FO5/PsUz6pfv+9/6/99+/kwEBOo2k6c83NhrZtOkaNkIDXa286fZ6hG
ouJaJIwTmT+i2iuZYdQ3GeOKTb15qGJ4oxQsMglbWo60iGYcSQ8Zjrh
GUhwighHpcRSBHIEZ8ajuV35dmBwIPK1DRxmMX6ADnqmre1Rb/D86UF
hNYX1lZncZpZNkGpmQ41PE87J6RFTe4YjND4hPDq943NwfIcEmv0AJV
GI7F4tMXViYVgECgCjoWWPm66DqOurXmEMcvp1A3/br1pGV5Oq1ZqVW
Hzk0ralCz5xDH79a5vM6tnW4Rma5XgQPVk8kTXtTzmU5Jg1g6tm0hfr
ZwPLGPaVrstL6p0SYwEAPeLLy9/vbeigxysgsPgIESGTYVcCYMnL/Bh
qbRKoQsq2NFBdt+LBbIp4y3CqiweBzK5LJIAu4vbD18f7Dfg77PdT1e
bt3S4/q2w5J39IbQfFW+TitzaqrA3ahSkOLf0MPVFhw/vhf3SYUqcOC
PczkoKQfXaNILCMb5df8Rd6QRS0hqKpoihHdgfNcm2Cj7ryTnKGC4K8
jnh3gmXQRElVwYyxI8x4FMqOzsu4edrKK6hNZ5wU7V9G+ufEFL+7yHi
cySQMp7wDHnRsBW571ORGn6fH2fUDj/FwV0iUbXPMbmTP7akkt8ChiL
/BPceEs3j1M3iz71/ObT9tA==<<<`;
// Simple linear congruential random number generator
class Random {
constructor(seed) {
this.prev = seed;
}
next() {
let curr = (1103515245 * this.prev + 12345) & 0x7fffffff;
this.prev = curr;
return curr >> 16;
}
}
describe("lib/factorio/exchange_string", function() {
describe("readMapExchangeString", function() {
it("should parse a valid string", function() {
let result = libFactorio.readMapExchangeString(defaultString);
assert.equal(result.map_gen_settings.seed, 1234567890);
result = libFactorio.readMapExchangeString(testString);
assert.equal(result.checksum, 4092204126);
});
it("should handle malformed strings", function() {
assert.throws(
() => libFactorio.readMapExchangeString("<<blah>>"),
new Error("Not a map exchange string")
);
assert.throws(
() => libFactorio.readMapExchangeString(defaultString.slice(0, 100)),
new Error("Not a map exchange string")
);
assert.throws(
() => libFactorio.readMapExchangeString(`>>>${Buffer.from("abk430ia404ah3b4").toString("base64")}<<<`),
new Error("Malformed map exchange string: zlib inflate failed")
);
for (let i = 0; i < 100; i++) {
let gen = new Random(i);
let size = gen.next() % 200 + 100;
let data = Buffer.alloc(size);
for (let j = 0; j < size; j++) {
data[j] = gen.next() % 256;
}
// eslint-disable-next-line node/no-sync
data = zlib.deflateSync(data);
assert.throws(
() => libFactorio.readMapExchangeString(`>>>${data.toString("base64")}<<<`),
new Error("Malformed map exchange string: reached end before finishing parsing")
);
}
let pastEnd = Buffer.from(defaultString.replace(/><\n/g, ""), "base64");
// eslint-disable-next-line node/no-sync
pastEnd = zlib.deflateSync(Buffer.concat([zlib.inflateSync(pastEnd), Buffer.from("junk")]));
assert.throws(
() => libFactorio.readMapExchangeString(`>>>${pastEnd.toString("base64")}<<<`),
new Error("Malformed map exchange string: data after end")
);
});
});
});