Skip to content

Commit 21dfc16

Browse files
committed
Stabilize browser playback after truncated replay loads
Current logic-generated replay files can end with a truncated length-delimited protobuf frame while still containing thousands of valid frames. The playback UI treated that as a hard load failure, kept the partially parsed reader alive, and let Play resurrect the failed state. Repeated WebGL loads also retained old readers and blob URLs, increasing heap pressure. Constraint: Website-facing WebGL API must remain compatible Rejected: Treat all truncated replays as hard failures | would reject currently usable logic-generated replays Confidence: high Scope-risk: moderate Directive: Do not reintroduce failed-reader recovery without testing repeated truncated WebGL loads Tested: MessageReader parses latest v9 truncated sample as 11865-frame partial load Tested: MessageReader parses old v7 sample as 1201 complete frames Tested: Empty, bad magic, and unknown version samples stay failed with zero buffered frames Tested: Unity Playback WebGL build completed successfully Tested: Unity-Playback-WebGL.zip matches playback directory SHA256 for all 17 files Tested: dotnet format whitespace Unity-Playback scripts --verify-no-changes Tested: WebGL root/live/playback/trial HTTP smoke returned 200 from interface Unity-WebGL root Not-tested: Browser upload loop on deployed site
1 parent c164774 commit 21dfc16

9 files changed

Lines changed: 290 additions & 57 deletions

File tree

interface/Unity/Unity-Playback/Assets/Plugins/WebGL/THUAI9WebGLBridge.jslib

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
mergeInto(LibraryManager.library, {
22
$THUAI9WebGLBridge: {
33
maxBase64Bytes: 16 * 1024 * 1024,
4+
maxRemotePlaybackBytes: 64 * 1024 * 1024,
5+
activePlaybackObjectUrl: null,
46
sendMessage: function (gameObjectName, methodName, payload) {
57
if (typeof SendMessage === 'function') { SendMessage(gameObjectName, methodName, payload || ''); return; }
68
var unityInstance = window.unityInstance || window.THUAIGameInstance || window.gameInstance || (typeof Module !== 'undefined' ? Module : null);
@@ -35,13 +37,47 @@ mergeInto(LibraryManager.library, {
3537
dispatchCustomEvent: function (eventName, detail) {
3638
if (typeof window.CustomEvent === 'function') { window.dispatchEvent(new CustomEvent(eventName, { detail: detail })); return; }
3739
var event = document.createEvent('CustomEvent'); event.initCustomEvent(eventName, false, false, detail); window.dispatchEvent(event);
40+
},
41+
revokeActivePlaybackObjectUrl: function () {
42+
if (!THUAI9WebGLBridge.activePlaybackObjectUrl) return;
43+
try { URL.revokeObjectURL(THUAI9WebGLBridge.activePlaybackObjectUrl); } catch (_) {}
44+
THUAI9WebGLBridge.activePlaybackObjectUrl = null;
45+
},
46+
isTerminalPlaybackStatus: function (payload) {
47+
if (!payload) return false;
48+
try {
49+
var status = typeof payload === 'string' ? JSON.parse(payload) : payload;
50+
if (status && status.loaded) return true;
51+
var text = status && status.statusText ? String(status.statusText) : '';
52+
return text && text.indexOf('正在') < 0;
53+
} catch (_) {
54+
return false;
55+
}
3856
}
3957
},
4058
THUAI9_SelectPlaybackFile__deps: ['$THUAI9WebGLBridge'],
4159
THUAI9_SelectPlaybackFile: function (gameObjectNamePtr, callbackNamePtr) {
4260
var gameObjectName = UTF8ToString(gameObjectNamePtr); var callbackName = UTF8ToString(callbackNamePtr);
4361
var input = document.createElement('input'); input.type = 'file'; input.accept = '.thuaipb,application/octet-stream'; input.style.display = 'none';
44-
input.onchange = function () { var file = input.files && input.files[0]; if (!file) { input.remove(); return; } var url = URL.createObjectURL(file); var payload = JSON.stringify({ url: url, name: file.name || 'playback.thuaipb', size: file.size || 0 }); THUAI9WebGLBridge.sendMessage(gameObjectName, callbackName, payload); THUAI9WebGLBridge.dispatchCustomEvent('thuai9-playback-file-selected', { url: url, name: file.name, size: file.size }); input.remove(); };
62+
input.onchange = function () {
63+
var file = input.files && input.files[0];
64+
if (!file) { input.remove(); return; }
65+
var size = file.size || 0;
66+
var name = file.name || 'playback.thuaipb';
67+
if (size > THUAI9WebGLBridge.maxRemotePlaybackBytes) {
68+
THUAI9WebGLBridge.dispatchCustomEvent('thuai9-playback-error', 'file-too-large:' + size);
69+
input.remove();
70+
return;
71+
}
72+
73+
THUAI9WebGLBridge.revokeActivePlaybackObjectUrl();
74+
var url = URL.createObjectURL(file);
75+
THUAI9WebGLBridge.activePlaybackObjectUrl = url;
76+
var payload = JSON.stringify({ url: url, name: name, size: size });
77+
THUAI9WebGLBridge.sendMessage(gameObjectName, callbackName, payload);
78+
THUAI9WebGLBridge.dispatchCustomEvent('thuai9-playback-file-selected', { url: url, name: name, size: size });
79+
input.remove();
80+
};
4581
document.body.appendChild(input); input.click();
4682
},
4783
THUAI9_ClearDevelopmentConsole: function () {
@@ -83,5 +119,8 @@ mergeInto(LibraryManager.library, {
83119
var eventName = UTF8ToString(eventNamePtr); var payload = UTF8ToString(payloadPtr);
84120
THUAI9WebGLBridge.dispatchCustomEvent('thuai9-unity-event', { eventName: eventName, payload: payload });
85121
THUAI9WebGLBridge.dispatchCustomEvent('thuai9-' + eventName, payload);
122+
if (eventName === 'playback-error' || (eventName === 'playback-status' && THUAI9WebGLBridge.isTerminalPlaybackStatus(payload))) {
123+
THUAI9WebGLBridge.revokeActivePlaybackObjectUrl();
124+
}
86125
}
87126
});

interface/Unity/Unity-Playback/Assets/Scripts/Playback/MessageReader.cs

Lines changed: 158 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@
77

88
namespace THUAI9.Unity.Playback
99
{
10+
public sealed class PlaybackFileIncompleteException : Exception
11+
{
12+
public PlaybackFileIncompleteException(string message, int parsedFrameCount)
13+
: base(message)
14+
{
15+
ParsedFrameCount = parsedFrameCount;
16+
}
17+
18+
public int ParsedFrameCount { get; }
19+
}
20+
1021
/// <summary>
1122
/// THUAI9 ????????
1223
/// ?????????
@@ -28,6 +39,9 @@ public class MessageReader
2839
public uint PlayerCount { get; private set; }
2940
public byte FileVersion { get; private set; }
3041
public bool IsLegacyVersion => FileVersion != 0 && FileVersion != PlayBackConstant.FILE_VERSION;
42+
public bool IsIncompleteTail { get; private set; }
43+
public string LoadWarning { get; private set; }
44+
public int DecompressedByteCount { get; private set; }
3145

3246
public void LoadData(byte[] data)
3347
{
@@ -36,8 +50,12 @@ public void LoadData(byte[] data)
3650
throw new ObjectDisposedException(nameof(MessageReader));
3751
}
3852

39-
messages.Clear();
40-
currentMsgIndex = -1;
53+
ResetLoadedData();
54+
55+
if (data == null || data.Length < 12)
56+
{
57+
throw new FormatException("回放文件头不完整。");
58+
}
4159

4260
using (var headerStream = new MemoryStream(data))
4361
using (var headerReader = new BinaryReader(headerStream))
@@ -65,17 +83,17 @@ public void LoadData(byte[] data)
6583
byte[] compressedData = new byte[data.Length - headerSize];
6684
Array.Copy(data, headerSize, compressedData, 0, compressedData.Length);
6785

68-
using (var compressedStream = new MemoryStream(compressedData))
69-
using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress))
70-
using (var decompressedStream = new MemoryStream())
71-
{
72-
gzipStream.CopyTo(decompressedStream);
73-
byte[] decompressedData = decompressedStream.ToArray();
86+
byte[] decompressedData = DecompressWithLimit(compressedData, out bool gzipEndedEarly);
87+
DecompressedByteCount = decompressedData.Length;
7488

75-
if (!TryLoadIndexedMessages(decompressedData))
76-
{
77-
LoadOfficialStreamMessages(decompressedData);
78-
}
89+
if (!TryLoadIndexedMessages(decompressedData))
90+
{
91+
LoadOfficialStreamMessages(decompressedData, gzipEndedEarly);
92+
}
93+
else if (gzipEndedEarly)
94+
{
95+
IsIncompleteTail = true;
96+
LoadWarning = "回放文件末尾缺少 GZip 结束标记,已加载可读取帧。";
7997
}
8098

8199
Reset();
@@ -151,6 +169,18 @@ public void Dispose()
151169
disposed = true;
152170
}
153171

172+
private void ResetLoadedData()
173+
{
174+
messages.Clear();
175+
currentMsgIndex = -1;
176+
TeamCount = 0;
177+
PlayerCount = 0;
178+
FileVersion = 0;
179+
IsIncompleteTail = false;
180+
LoadWarning = null;
181+
DecompressedByteCount = 0;
182+
}
183+
154184
private bool TryLoadIndexedMessages(byte[] decompressedData)
155185
{
156186
try
@@ -218,23 +248,131 @@ private bool TryLoadIndexedMessages(byte[] decompressedData)
218248
}
219249
}
220250

221-
private void LoadOfficialStreamMessages(byte[] decompressedData)
251+
private void LoadOfficialStreamMessages(byte[] decompressedData, bool gzipEndedEarly)
222252
{
253+
var parsedMessages = new List<MessageToClient>();
254+
int offset = 0;
255+
256+
while (offset < decompressedData.Length)
257+
{
258+
if (parsedMessages.Count >= PlayBackConstant.MAX_MESSAGE_COUNT)
259+
{
260+
throw new FormatException($"Playback message count exceeds {PlayBackConstant.MAX_MESSAGE_COUNT}.");
261+
}
262+
263+
if (!TryReadRawVarint32(decompressedData, ref offset, out int messageLength))
264+
{
265+
AcceptOrRejectIncompleteTail(parsedMessages, "回放文件末尾不完整:最后一帧长度字段未写完。");
266+
return;
267+
}
268+
269+
if (messageLength < 0)
270+
{
271+
throw new FormatException("回放帧长度字段异常。");
272+
}
273+
274+
if (offset + messageLength > decompressedData.Length)
275+
{
276+
int missingBytes = offset + messageLength - decompressedData.Length;
277+
AcceptOrRejectIncompleteTail(parsedMessages, $"回放文件末尾不完整:最后一帧缺少 {missingBytes} 字节。");
278+
return;
279+
}
280+
281+
try
282+
{
283+
parsedMessages.Add(MessageToClient.Parser.ParseFrom(decompressedData, offset, messageLength));
284+
}
285+
catch (InvalidProtocolBufferException ex)
286+
{
287+
throw new FormatException("回放帧数据损坏,无法解析。", ex);
288+
}
289+
290+
offset += messageLength;
291+
}
292+
223293
messages.Clear();
294+
messages.AddRange(parsedMessages);
295+
if (gzipEndedEarly && parsedMessages.Count > 0)
296+
{
297+
IsIncompleteTail = true;
298+
LoadWarning = "回放文件末尾缺少 GZip 结束标记,已加载可读取帧。";
299+
}
300+
}
224301

225-
using (var protobufStream = new CodedInputStream(decompressedData))
302+
private static bool TryReadRawVarint32(byte[] data, ref int offset, out int value)
303+
{
304+
value = 0;
305+
int shift = 0;
306+
while (shift < 32)
226307
{
227-
while (!protobufStream.IsAtEnd)
308+
if (offset >= data.Length)
228309
{
229-
if (messages.Count >= PlayBackConstant.MAX_MESSAGE_COUNT)
310+
return false;
311+
}
312+
313+
byte b = data[offset++];
314+
value |= (b & 0x7F) << shift;
315+
if ((b & 0x80) == 0)
316+
{
317+
return true;
318+
}
319+
320+
shift += 7;
321+
}
322+
323+
throw new FormatException("回放帧长度字段异常。");
324+
}
325+
326+
private void AcceptOrRejectIncompleteTail(List<MessageToClient> parsedMessages, string warning)
327+
{
328+
if (parsedMessages.Count == 0)
329+
{
330+
throw new PlaybackFileIncompleteException(warning, parsedMessages.Count);
331+
}
332+
333+
messages.Clear();
334+
messages.AddRange(parsedMessages);
335+
IsIncompleteTail = true;
336+
LoadWarning = warning;
337+
}
338+
339+
private static byte[] DecompressWithLimit(byte[] compressedData, out bool endedEarly)
340+
{
341+
endedEarly = false;
342+
using (var compressedStream = new MemoryStream(compressedData))
343+
using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress))
344+
using (var decompressedStream = new MemoryStream())
345+
{
346+
byte[] buffer = new byte[81920];
347+
while (true)
348+
{
349+
int read;
350+
try
351+
{
352+
read = gzipStream.Read(buffer, 0, buffer.Length);
353+
}
354+
catch (Exception ex) when (
355+
decompressedStream.Length > 0
356+
&& (ex is InvalidDataException || ex is IOException || ex is EndOfStreamException))
357+
{
358+
endedEarly = true;
359+
break;
360+
}
361+
362+
if (read == 0)
230363
{
231-
throw new FormatException($"Playback message count exceeds {PlayBackConstant.MAX_MESSAGE_COUNT}.");
364+
break;
232365
}
233366

234-
var message = new MessageToClient();
235-
protobufStream.ReadMessage(message);
236-
messages.Add(message);
367+
if (decompressedStream.Length + read > PlayBackConstant.MAX_DECOMPRESSED_PLAYBACK_BYTES)
368+
{
369+
throw new InvalidDataException($"回放解压后超过 {PlayBackConstant.MAX_DECOMPRESSED_PLAYBACK_BYTES} 字节。");
370+
}
371+
372+
decompressedStream.Write(buffer, 0, read);
237373
}
374+
375+
return decompressedStream.ToArray();
238376
}
239377
}
240378
}

interface/Unity/Unity-Playback/Assets/Scripts/Playback/PlayBackConstant.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public static class PlayBackConstant
1717
public const int FILE_VERSION = 9;
1818
public const int LEGACY_FILE_VERSION = 7;
1919
public const int MAX_MESSAGE_COUNT = 100000;
20+
public const int MAX_DECOMPRESSED_PLAYBACK_BYTES = 256 * 1024 * 1024;
2021
public const string DEFAULT_PLAYBACK_PATH = "Playback/";
2122
public const string PLAYBACK_EXTENSION = ".thuaipb";
2223

0 commit comments

Comments
 (0)