Add map record HUD announcements for T5 and T6

This commit is contained in:
2026-10-05 22:14:27 -04:00
parent bbafebe208
commit 05a906c3e2
11 changed files with 1967 additions and 20 deletions
+9 -2
View File
@@ -13,10 +13,16 @@ Copy all `tws_*.gsc` to `storage/t5/scripts/sp/` on the Plutonium T5 server
## Platform quirks (T5 SP engine, Plutonium)
- **Initialize locals before loops when reading them afterwards.** T5 rejected
leaderboard's `chosen` variable at load time when its only assignment was
inside the map-search loop, even though its later use was guarded by the
match count. It now starts as `undefined` before that loop in both versions.
The JavaScript fixture harness and T6 compiler did not catch this T5 error.
- **One `level waittill("connected")` only.** `tws_common` owns it. A
second listener breaks the T5 SP connect handshake. Per-player work goes
through `tws_common::register_spawn_handler` and gets dispatched after
`spawned_player`.
through `tws_common::register_spawn_handler` after `spawned_player`, or
`register_connect_handler` for spectator-safe work after the dispatcher yields.
Leaderboard uses the latter; its level worker owns all HTTP work.
- **Chat uses `level waittill("say", ...)`**, not `callbackPlayerSay`. T5 SP
dedicated disables the sound subsystem, cascading through the say-callback
dispatch — `callbackPlayerSay` never fires.
@@ -84,3 +90,4 @@ Copy all `tws_*.gsc` to `storage/t5/scripts/sp/` on the Plutonium T5 server
| `tws_join` | `.join` for spectators who have not played on this connection | — | `say`, `end_game`, `game_ended`, `intermission` |
| `tws_lock` | Lobby `g_password` lock at milestone round (`tws_lock_round`, default 20) | — | `hud_round_changed`, `hud_game_over` |
| `tws_telemetry` | Ordered JSON game start, round, 30-second heartbeat, and game end reports with full player snapshots | `telemetry_queued`, `telemetry_finished` (internal) | `end_game`, `game_ended`, `intermission` |
| `tws_leaderboard` | Cached completed-match map record and all tied holders, privately on join | — | `end_game`, `game_ended`, `intermission` |
+24 -4
View File
@@ -1,7 +1,7 @@
# tws-t5-aio
Custom HUD + persistent bank + AFK toggle + immediate join + lobby lock + game telemetry for Plutonium T5
(Black Ops 1) zombies dedicated servers. Split across eight `.gsc` files,
Custom HUD + persistent bank + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5
(Black Ops 1) zombies dedicated servers. Split across nine `.gsc` files,
each installable / removable in isolation (with `tws_common` as the shared
hub).
@@ -11,7 +11,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| File | Role |
|---|---|
| [tws_common.gsc](tws_common.gsc) | Shared config, HUD helpers, `.help`, spawn-handler registry, single `connected` listener + dispatcher |
| [tws_common.gsc](tws_common.gsc) | Shared config, HUD helpers, `.help`, connect/spawn registries, single `connected` listener + dispatcher |
| [tws_db.gsc](tws_db.gsc) | MariaDB pool config + reachability probe |
| [tws_hud.gsc](tws_hud.gsc) | Bottom-left HUD stack: timers, zombie count, health bar |
| [tws_bank.gsc](tws_bank.gsc) | Persistent per-player bank + console commands |
@@ -19,6 +19,22 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| [tws_join.gsc](tws_join.gsc) | `.join` lets newcomers spawn before the current round ends |
| [tws_lock.gsc](tws_lock.gsc) | Lobby `g_password` lock at a milestone round |
| [tws_telemetry.gsc](tws_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
| [tws_leaderboard.gsc](tws_leaderboard.gsc) | Private map high-round record and all tied holders on connection |
## Map record announcements
Copy `tws_leaderboard.gsc` and the updated `tws_common.gsc` to
`storage/t5/scripts/sp/`, then restart the map. Leaderboard requires a
[t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) build exposing
`http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade
requires a full server-process restart. To omit leaderboard, remove its script;
the enable dvar does not bypass native linking dependencies.
Records are read from the public Electric Cherry API with no credentials and
work independently of telemetry, HUD and the bank. Spectator newcomers receive
the same private announcement as playable joins. T5 matches its engine map to
the API's map list. [Shared configuration, caching and validation](../README.md#map-record-announcements)
apply to both games.
## Player commands
@@ -58,12 +74,14 @@ graph BT
bank[tws_bank]
lock[tws_lock]
telemetry[tws_telemetry]
leaderboard[tws_leaderboard]
db --> common
hud --> common
afk --> common
join --> common
telemetry --> common
leaderboard --> common
bank -. db_probe_done .-> db
lock -. hud_round_changed<br/>hud_game_over .-> hud
@@ -73,7 +91,7 @@ graph BT
- All other cross-module coupling flows through level notifies
(`db_probe_done`, `hud_round_changed`, `hud_match_started`,
`hud_game_over`) or through spawn handlers registered with
`tws_common::register_spawn_handler`.
`tws_common::register_spawn_handler` / `register_connect_handler`.
- Any file except `tws_common` can be removed; the rest still load.
`tws_afk` has no downstream consumers — pull it independently.
`tws_bank` needs `tws_db`; `tws_lock` needs `tws_hud`.
@@ -99,6 +117,7 @@ connect listener anywhere else crashes the T5 SP handshake.
- Shared HUD constants: `level.hud_x`, `hud_y`, `hud_row_*`, `hud_col_*`,
`hud_font_*`, `hud_bar_*`, `hud_c_*`, `hud_rate`, `hud_health_threshold`
- Spawn-handler registry
- Connection-handler registry for optional modules that serve spectator newcomers
- `.help` listener and help registry populated by installed command modules
- Connect listener + per-player dispatch: iterates
`level.tws_spawn_handlers` after `spawned_player` and threads each on
@@ -110,6 +129,7 @@ connect listener anywhere else crashes the T5 SP handshake.
| `common_config()` | Populate every `level.hud_*` (idempotent) |
| `ensure_config()` | No-op if config already set; called by every module's `init` |
| `register_spawn_handler(fn)` | Appends `::fn` to `level.tws_spawn_handlers` |
| `register_connect_handler(fn)` | Threads `::fn` after connection dispatch yields, without waiting for playable spawn |
| `register_help_command(command, description)` | Adds a player command to `.help`; call from the owning module's `init()` |
| `make_text_elem(p, x, y, ax, s, c)`| `NewClientHudElem` text element with black-glow outline |
| `make_shader_elem(p, x, y)` | `NewClientHudElem` shader element |
+24 -1
View File
@@ -1,4 +1,4 @@
/* tws_common.gsc — shared config, HUD helpers, spawn/help registries, connect dispatcher. */
/* tws_common.gsc — shared config, HUD helpers, connect/spawn/help registries. */
#include maps\_utility;
#include common_scripts\utility;
@@ -74,6 +74,15 @@ register_spawn_handler(fn)
level.tws_spawn_handlers[level.tws_spawn_handlers.size] = fn;
}
// Connection handlers also serve newcomers still waiting in spectator mode.
register_connect_handler(fn)
{
if (!isDefined(level.tws_connect_handlers))
level.tws_connect_handlers = [];
level.tws_connect_handlers[level.tws_connect_handlers.size] = fn;
}
// Modules register their own entries so help only lists installed commands.
register_help_command(command, description)
{
@@ -159,9 +168,23 @@ on_player_connect_dispatch()
{
level waittill("connected", player);
player thread on_player_spawned_dispatch();
player thread on_player_connected_dispatch();
}
}
on_player_connected_dispatch()
{
self endon("disconnect");
// Leave the connection handshake before invoking optional modules.
wait 0.05;
if (!isDefined(level.tws_connect_handlers))
return;
for (i = 0; i < level.tws_connect_handlers.size; i++)
self thread [[ level.tws_connect_handlers[i] ]]();
}
// handlers own their own self endon("disconnect")
on_player_spawned_dispatch()
{
+744
View File
@@ -0,0 +1,744 @@
/* tws_leaderboard.gsc - private map record announcement, once per connection. */
#include maps\_utility;
#include common_scripts\utility;
init()
{
if (GetDvar(#"zombiemode") != "1")
return;
if (GetDvar("tws_leaderboard_enabled") == "0")
return;
level.tws_lb_game = "t5";
level.tws_lb_url = GetDvar("tws_leaderboard_url");
if (level.tws_lb_url == "")
level.tws_lb_url = "https://ec.thewrightserver.net/api/leaderboard";
if (getSubStr(toLower(level.tws_lb_url), 0, 8) != "https://")
{
lb_log("Disabled: tws_leaderboard_url must use HTTPS");
return;
}
level.tws_lb_override = GetDvar("tws_leaderboard_map_key");
level.tws_lb_pending = undefined;
level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0;
scripts\sp\tws_common::register_connect_handler(::lb_welcome);
level thread lb_worker();
}
lb_worker()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
// The same byte table supports UTF-8 validation and URL percent encoding.
level.tws_lb_bytes = [];
for (i = 1; i < 256; i++)
level.tws_lb_bytes[formatString("%c", i)] = i;
wait 1;
for (;;)
{
// A timed-out native request cannot be cancelled. Never overlap it.
while (isDefined(level.tws_lb_pending))
wait 1;
snapshot = lb_refresh();
level.tws_lb_attempt++;
if (isDefined(snapshot))
{
snapshot.saved_at = getTime();
level.tws_lb_cache = snapshot;
wait 300;
}
else
{
lb_log("Refresh failed; retaining the last complete record, if available");
wait 30;
}
}
}
lb_welcome()
{
self endon("disconnect");
level endon("end_game");
level endon("game_ended");
level endon("intermission");
if (isDefined(self.tws_lb_welcomed))
return;
self.tws_lb_welcomed = true;
// Spectator readiness matters too: do not wait for a playable spawn.
while (!isDefined(self.sessionstate) ||
(self.sessionstate != "playing" && self.sessionstate != "spectator"))
wait 0.25;
while (isDefined(self.hasSpawned) && !self.hasSpawned)
wait 0.25;
wait 5;
while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0)
wait 0.25;
if (!isDefined(level.tws_lb_cache))
return;
snapshot = level.tws_lb_cache;
// Do not advertise an indefinitely stale record during an outage.
if (getTime() - snapshot.saved_at > 900000)
return;
self lb_show_hud(snapshot);
}
lb_show_hud(snapshot)
{
self endon("disconnect");
self endon("tws_lb_hud_done");
scripts\sp\tws_common::ensure_config();
// Match .help's position, typography and row spacing below the health bar.
y = level.hud_row_bar + level.hud_bar_height + 20;
elems = [];
title = scripts\sp\tws_common::make_text_elem(
self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk);
title.alpha = 0;
if (snapshot.round == 0)
title SetText("No recorded high round yet");
else
title SetText("Record: Round " + snapshot.round);
elems[elems.size] = title;
for (i = 0; i < 3; i++)
{
row = scripts\sp\tws_common::make_text_elem(
self, level.hud_x, y + 22 + i * 18,
"left", level.hud_font_small, level.hud_c_count);
row.alpha = 0;
elems[elems.size] = row;
}
// Cleanup runs independently of the welcome thread's endon handlers.
self thread scripts\sp\tws_common::destroy_on_disconnect(elems);
self thread lb_hud_end_game(elems, "end_game");
self thread lb_hud_end_game(elems, "game_ended");
self thread lb_hud_end_game(elems, "intermission");
offset = 0;
for (;;)
{
// Text changes only once per page, never during the visibility loop.
for (i = 0; i < 3; i++)
{
row = elems[i + 1];
if (offset + i < snapshot.lines.size)
row SetText("^3Held by: ^7" + snapshot.lines[offset + i]);
else
row SetText("");
}
remaining = 12;
while (remaining > 0)
{
visible = !self lb_hud_help_visible();
for (i = 0; i < elems.size; i++)
elems[i].alpha = visible;
wait 0.1;
// Pause the page while .help occupies the same area.
if (visible)
remaining -= 0.1;
}
offset += 3;
if (offset >= snapshot.lines.size)
break;
}
lb_hud_destroy(elems);
self notify("tws_lb_hud_done");
}
lb_hud_help_visible()
{
return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 &&
isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0;
}
lb_hud_end_game(elems, event)
{
self endon("disconnect");
self endon("tws_lb_hud_done");
level waittill(event);
lb_hud_destroy(elems);
self notify("tws_lb_hud_done");
}
lb_hud_destroy(elems)
{
for (i = 0; i < elems.size; i++)
{
if (isDefined(elems[i]))
elems[i] Destroy();
}
}
lb_refresh()
{
selection = lb_selection();
if (!isDefined(selection))
{
lb_log("Cannot identify this map variant; set tws_leaderboard_map_key to its Electric Cherry key");
return undefined;
}
// Map keys are discovered and checked against the API's available maps.
discovery = lb_get("&limit=1&offset=0");
if (!isDefined(discovery) || !lb_page_valid(discovery, 0, 1))
return undefined;
if (!lb_resolve_map(selection, discovery["maps"]))
return undefined;
snapshot = spawnStruct();
snapshot.name = lb_display_text(selection.name, 64);
snapshot.round = 0;
snapshot.names = [];
snapshot.guids = [];
snapshot.lines = [];
snapshot.total = -1;
snapshot.last_round = 2147483647;
// No map option means no completed match has been recorded for this map.
if (!selection.found)
return snapshot;
offset = 0;
for (;;)
{
page = lb_get("&map=" + lb_url_encode(selection.key) + "&limit=3&offset=" + offset);
if (!isDefined(page) || !lb_page_valid(page, offset, 3))
return undefined;
result = lb_take_page(snapshot, page, selection.key, offset);
if (result < 0)
{
lb_log("Invalid or changing leaderboard; discarding incomplete refresh");
return undefined;
}
if (result == 1)
{
snapshot.lines = lb_holder_lines(snapshot.names);
return snapshot;
}
offset += page["entries"].size;
if (offset > 1000000)
{
lb_log("Leaderboard exceeds the API pagination limit");
return undefined;
}
wait 0.05;
}
}
lb_selection()
{
selection = spawnStruct();
selection.map = toLower(GetDvar("mapname"));
selection.name = selection.map;
selection.key = level.tws_lb_override;
selection.explicit = selection.key != "";
return selection;
}
// All HTTP and parsing work belongs to the level worker, never the join thread.
lb_get(query)
{
options = [];
options["method"] = "GET";
headers = [];
headers["Accept"] = "application/json";
options["headers"] = headers;
url = level.tws_lb_url + "?game=" + level.tws_lb_game + "&metric=highestRound" + query;
request = http::request(url, options);
if (!isDefined(request))
{
lb_log("HTTP request could not be created");
return undefined;
}
pending = spawnStruct();
pending.done = false;
level.tws_lb_pending = pending;
level thread lb_collect(request, pending);
started = getTime();
while (!pending.done && getTime() - started < 15000)
wait 0.1;
if (!pending.done)
{
lb_log("HTTP timed out; waiting for native completion before requesting again");
return undefined;
}
if (!isDefined(pending.success) || !pending.success)
{
lb_log("HTTP transport failed");
return undefined;
}
// Plugins expose neither HTTP status nor response headers. Reject error
// bodies by grammar and success-schema checks, not the transport flag.
if (!lb_json_valid(pending.body))
{
lb_log("Rejected malformed, oversized or non-JSON HTTP response");
return undefined;
}
return jsonParse(pending.body);
}
lb_collect(request, pending)
{
// Keep draining after game over; ending a GSC thread cannot cancel HTTP.
request waittill("done", body, success, error);
pending.body = body;
pending.success = success;
pending.done = true;
level.tws_lb_pending = undefined;
}
lb_log(message)
{
print("[tws_leaderboard] " + message);
}
// Pure data helpers below are exercised by tools/check_leaderboard.mjs.
lb_resolve_map(selection, maps)
{
selection.found = false;
matches = 0;
// T5 requires initialization before the loop, even with matches == 1 below.
chosen = undefined;
for (i = 0; i < maps.size; i++)
{
item = maps[i];
if (typeof(item) != "array" || typeof(item["game"]) != "string" ||
typeof(item["map"]) != "string" || typeof(item["key"]) != "string" ||
typeof(item["name"]) != "string")
return false;
if (item["game"] != level.tws_lb_game || item["map"] != selection.map)
continue;
if (selection.key != "" && item["key"] != selection.key)
continue;
matches++;
chosen = item;
}
if (matches > 1 || (matches == 0 && selection.explicit))
{
lb_log("Map key is absent, ambiguous or belongs to another map/game");
return false;
}
if (matches == 1)
{
selection.key = chosen["key"];
selection.name = chosen["name"];
selection.found = true;
}
return true;
}
lb_page_valid(page, offset, limit)
{
if (typeof(page) != "array" || typeof(page["entries"]) != "array" ||
typeof(page["maps"]) != "array" || typeof(page["summary"]) != "array")
return false;
if (typeof(page["metric"]) != "string" || !lb_uint(page["offset"]) || !lb_uint(page["limit"]))
return false;
if (page["metric"] != "highestRound" || page["offset"] != offset || page["limit"] != limit)
return false;
return lb_uint(page["total"]) && lb_uint(page["summary"]["highestRound"]) &&
page["entries"].size <= limit;
}
// -1 rejects the whole refresh, 0 requests another page, 1 is complete.
lb_take_page(snapshot, page, key, offset)
{
entries = page["entries"];
if (offset == 0)
{
snapshot.round = page["summary"]["highestRound"];
snapshot.total = page["total"];
}
if (snapshot.round != page["summary"]["highestRound"] || snapshot.total != page["total"])
return -1;
if (entries.size == 0)
{
if (offset == 0 && snapshot.total == 0 && snapshot.round == 0)
return 1;
return -1;
}
if (snapshot.round < 2 || offset + entries.size > snapshot.total)
return -1;
for (i = 0; i < entries.size; i++)
{
entry = entries[i];
if (typeof(entry) != "array" || !lb_uint(entry["highestRound"]) || !lb_uint(entry["rank"]))
return -1;
value = entry["highestRound"];
if (value > snapshot.last_round || value > snapshot.round)
return -1;
snapshot.last_round = value;
if (value < snapshot.round)
{
if (snapshot.names.size == 0)
return -1;
return 1;
}
if (entry["rank"] != 1 || typeof(entry["guid"]) != "string" || entry["guid"] == "" ||
typeof(entry["name"]) != "string" || typeof(entry["records"]) != "array")
return -1;
evidence = entry["records"]["highestRound"];
if (typeof(evidence) != "array" || typeof(evidence["game"]) != "string" ||
typeof(evidence["mapKey"]) != "string" || !lb_uint(evidence["value"]))
return -1;
if (evidence["game"] != level.tws_lb_game ||
evidence["mapKey"] != key || evidence["value"] != snapshot.round)
return -1;
// GUIDs remain strings; equal names are distinct players.
guid = entry["guid"];
if (!isDefined(snapshot.guids[guid]))
{
snapshot.guids[guid] = true;
name = lb_display_text(entry["name"], 64);
if (name == "")
name = lb_display_text(guid, 64);
snapshot.names[snapshot.names.size] = name;
}
}
if (offset + entries.size >= snapshot.total)
return 1;
return 0;
}
lb_uint(value)
{
return typeof(value) == "int" && value >= 0;
}
lb_holder_lines(names)
{
lines = [];
line = "";
for (i = 0; i < names.size; i++)
{
if (line != "" && line.size + names[i].size + 2 > 100)
{
lines[lines.size] = line;
line = "";
}
if (line != "")
line += ", ";
line += names[i];
}
if (line != "")
lines[lines.size] = line;
return lines;
}
lb_display_text(value, limit)
{
output = "";
for (i = 0; i < value.size; i++)
{
ch = getSubStr(value, i, i + 1);
code = level.tws_lb_bytes[ch];
if (!isDefined(code) || code < 32 || code == 127)
continue;
if (ch == "^")
{
// Strip game colour escapes; never let a name inject formatting.
if (i + 1 < value.size && lb_contains("0123456789", getSubStr(value, i + 1, i + 2)))
i++;
continue;
}
width = 1;
if (code >= 240)
width = 4;
else if (code >= 224)
width = 3;
else if (code >= 192)
width = 2;
if (output.size + width > limit)
return output + "...";
output += getSubStr(value, i, i + width);
i += width - 1;
}
return output;
}
lb_url_encode(value)
{
hex = "0123456789ABCDEF";
encoded = "";
for (i = 0; i < value.size; i++)
{
ch = getSubStr(value, i, i + 1);
if (lb_contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", ch))
encoded += ch;
else
{
code = level.tws_lb_bytes[ch];
high = int(code / 16);
low = code % 16;
encoded += "%" + getSubStr(hex, high, high + 1) + getSubStr(hex, low, low + 1);
}
}
return encoded;
}
lb_contains(chars, ch)
{
if (ch == "")
return false;
for (j = 0; j < chars.size; j++)
{
if (getSubStr(chars, j, j + 1) == ch)
return true;
}
return false;
}
// jsonParse raises a script error on malformed input. Validate the complete
// grammar, UTF-8 and escapes first, including truncated bodies and bad proxies.
// Yield during scanning so network data cannot monopolize a server frame.
lb_json_valid(body)
{
if (typeof(body) != "string" || body.size == 0 || body.size >= 20480)
return false;
if (!lb_utf8_valid(body))
return false;
state = spawnStruct();
state.body = body;
state.pos = 0;
state.yield_at = 256;
if (!lb_json_value(state, 0))
return false;
lb_json_space(state);
return state.pos == body.size;
}
lb_utf8_valid(body)
{
for (i = 0; i < body.size; i++)
{
if (i % 256 == 0)
wait 0.05;
code = level.tws_lb_bytes[getSubStr(body, i, i + 1)];
if (!isDefined(code))
return false;
if (code < 128)
continue;
count = 0;
minimum = 128;
maximum = 191;
if (code >= 194 && code <= 223)
count = 1;
else if (code >= 224 && code <= 239)
{
count = 2;
if (code == 224)
minimum = 160;
if (code == 237)
maximum = 159;
}
else if (code >= 240 && code <= 244)
{
count = 3;
if (code == 240)
minimum = 144;
if (code == 244)
maximum = 143;
}
if (count == 0 || i + count >= body.size)
return false;
for (j = 1; j <= count; j++)
{
next = level.tws_lb_bytes[getSubStr(body, i + j, i + j + 1)];
if (!isDefined(next) || next < minimum || next > maximum)
return false;
minimum = 128;
maximum = 191;
}
i += count;
}
return true;
}
lb_json_char(state)
{
if (state.pos >= state.yield_at)
{
wait 0.05;
state.yield_at = state.pos + 256;
}
if (state.pos >= state.body.size)
return "";
return getSubStr(state.body, state.pos, state.pos + 1);
}
lb_json_space(state)
{
while (lb_contains(" \t\r\n", lb_json_char(state)))
state.pos++;
}
lb_json_value(state, depth)
{
if (depth > 16)
return false;
lb_json_space(state);
ch = lb_json_char(state);
if (ch == "\"")
return lb_json_string(state);
if (ch == "{" || ch == "[")
{
object = ch == "{";
close = "]";
if (object)
close = "}";
state.pos++;
lb_json_space(state);
if (lb_json_char(state) == close)
{
state.pos++;
return true;
}
for (;;)
{
if (object)
{
if (!lb_json_string(state))
return false;
lb_json_space(state);
if (lb_json_char(state) != ":")
return false;
state.pos++;
}
if (!lb_json_value(state, depth + 1))
return false;
lb_json_space(state);
ch = lb_json_char(state);
state.pos++;
if (ch == close)
return true;
if (ch != ",")
return false;
lb_json_space(state);
}
}
literals = strTok("true,false,null", ",");
for (i = 0; i < literals.size; i++)
{
literal = literals[i];
if (state.pos + literal.size <= state.body.size &&
getSubStr(state.body, state.pos, state.pos + literal.size) == literal)
{
state.pos += literal.size;
return true;
}
}
return lb_json_number(state);
}
lb_json_string(state)
{
if (lb_json_char(state) != "\"")
return false;
state.pos++;
while (state.pos < state.body.size)
{
ch = lb_json_char(state);
state.pos++;
if (ch == "\"")
return true;
if (level.tws_lb_bytes[ch] < 32)
return false;
if (ch != "\\")
continue;
ch = lb_json_char(state);
state.pos++;
if (lb_contains("\"\\/bfnrt", ch))
continue;
if (ch != "u")
return false;
code = lb_json_hex4(state);
// Embedded NUL does not round-trip through engine strings.
if (code <= 0 || (code >= 56320 && code <= 57343))
return false;
if (code >= 55296 && code <= 56319)
{
if (state.pos + 2 > state.body.size ||
getSubStr(state.body, state.pos, state.pos + 2) != "\\u")
return false;
state.pos += 2;
code = lb_json_hex4(state);
if (code < 56320 || code > 57343)
return false;
}
}
return false;
}
lb_json_hex4(state)
{
hex = "0123456789abcdef";
result = 0;
for (i = 0; i < 4; i++)
{
ch = toLower(lb_json_char(state));
if (!lb_contains(hex, ch))
return -1;
for (j = 0; j < 16; j++)
{
if (getSubStr(hex, j, j + 1) == ch)
break;
}
result = result * 16 + j;
state.pos++;
}
return result;
}
lb_json_number(state)
{
start = state.pos;
if (lb_json_char(state) == "-")
state.pos++;
ch = lb_json_char(state);
if (ch == "0")
state.pos++;
else
{
if (!lb_contains("123456789", ch))
return false;
while (lb_contains("0123456789", lb_json_char(state)))
state.pos++;
}
if (lb_json_char(state) == ".")
{
state.pos++;
if (!lb_contains("0123456789", lb_json_char(state)))
return false;
while (lb_contains("0123456789", lb_json_char(state)))
state.pos++;
}
if (lb_contains("eE", lb_json_char(state)))
{
state.pos++;
if (lb_contains("+-", lb_json_char(state)))
state.pos++;
exponent = state.pos;
if (!lb_contains("0123456789", lb_json_char(state)))
return false;
while (lb_contains("0123456789", lb_json_char(state)))
state.pos++;
// API counters/ratios never need huge exponents; prevent native overflow.
if (state.pos - exponent > 2 || int(getSubStr(state.body, exponent, state.pos)) > 38)
return false;
}
return state.pos - start <= 32;
}