Description
On a cold client cache, entering an area containing never-before-seen graphics makes the game very laggy and can freeze character movement until the images arrive. Repeating the same route with a warm cache is much smoother.
Classic does have bounded parallel QUIC asset downloads: up to three typed SOCKET_STREAM_ASSET streams are opened, and the network loop prioritizes the gameplay stream before servicing bulk streams. Those streams currently cover the listing/server files, resources, and region maps.
On-demand face graphics bypass that machinery. A missing face sends legacy SERVER_CMD_ASK_FACE; the server embeds the entire PNG in CLIENT_CMD_IMAGE and enqueues it on the long-lived ordered gameplay FIFO. The client network thread cannot enqueue the next game command until that complete framed image command is read. The main/render thread then atomically writes and decodes each PNG synchronously, and its command budget only checks after the whole handler finishes. Cold-cache face bursts can therefore both head-of-line block gameplay traffic and overrun a frame while writing, decoding, and redrawing.
This is the gap behind the reported behavior: parallel QUIC exists, but dynamic face/image downloads do not use it.
Steps to reproduce
Start a Classic client with an empty or isolated server-image cache.
Log in and walk into an area that introduces many previously unseen faces.
Continue issuing movement commands while the graphics arrive.
Compare frame/input responsiveness with a second pass using the warmed cache.
Expected
Movement, input, map updates, and rendering stay responsive while missing graphics download with bounded concurrency.
Actual
The game visibly lags and movement can stop until cold-cache graphics have streamed in.
Technical notes
Three-stream asset bound:
/** QUIC application-stream contract version carried by every stream preface. */
#define SOCKET_STREAM_PROTOCOL_VERSION 1
/** Fixed typed-stream preface size. */
#define SOCKET_STREAM_PREFACE_SIZE 8U
/** Reset codes used by the typed-stream protocol. */
#define SOCKET_STREAM_ERROR_PREFACE 1U
#define SOCKET_STREAM_ERROR_CANCELLED 2U
#define SOCKET_STREAM_ERROR_CLIENT_PROTOCOL 3U
#define SOCKET_STREAM_ERROR_LIMIT 4U
#define SOCKET_STREAM_ERROR_SERVER_PROTOCOL 5U
/** Maximum simultaneous in-band asset streams opened by one client. */
#define ASSET_STREAM_ACTIVE_MAX 3U
/** Maximum queued in-band asset requests retained by one client connection. */
#define ASSET_REQUEST_PENDING_MAX 64U
/** Per-stream bytes serviced before yielding to another asset stream. */
#define ASSET_STREAM_QUANTUM (16U * 1024U)
Parallel scheduler:
bool asset_requests_service (socket_t * sc , bool * write_pending ) {
HARD_ASSERT (sc != NULL );
HARD_ASSERT (write_pending != NULL );
* write_pending = false;
if (asset_mutex == NULL ) {
return false;
}
SDL_LockMutex (asset_mutex );
size_t active = 0 ;
asset_request_t * request , * next ;
HASH_ITER (hh , asset_requests , request , next ) {
if (request -> stream != NULL ) {
active ++ ;
}
}
HASH_ITER (hh , asset_requests , request , next ) {
if (active >= ASSET_STREAM_ACTIVE_MAX ) {
break ;
}
if (request -> state != ASSET_REQUEST_PENDING || request -> cancelled ||
request -> transport_state != ASSET_TRANSPORT_QUEUED ) {
continue ;
}
request -> stream = socket_stream_open (sc , SOCKET_STREAM_ASSET );
if (request -> stream == NULL ) {
break ;
}
request -> transport_state = ASSET_TRANSPORT_SEND_REQUEST ;
active ++ ;
}
bool progressed = false;
HASH_ITER (hh , asset_requests , request , next ) {
if (request -> stream != NULL ) {
progressed |= asset_request_service (request );
}
if (request -> cancelled && request -> stream == NULL ) {
HASH_DEL (asset_requests , request );
asset_request_destroy (request );
}
}
HASH_ITER (hh , asset_requests , request , next ) {
if (request -> state == ASSET_REQUEST_PENDING &&
request -> transport_state == ASSET_TRANSPORT_SEND_REQUEST ) {
* write_pending = true;
break ;
}
}
SDL_UnlockMutex (asset_mutex );
return progressed ;
Gameplay priority:
size_t amt ;
if (!socket_read (sc , readbuf + readbuf_len , (size_t )toread , & amt )) {
break ;
}
if (amt != 0 ) {
progressed = true;
readbuf_len += (int )amt ;
network_graph_update (NETWORK_GRAPH_TYPE_GAME , NETWORK_GRAPH_TRAFFIC_RX , amt );
if (readbuf_len == cmd_len + header_len && !socket_thread_aborted ()) {
command_buffer * input =
command_buffer_new (readbuf_len - header_len , readbuf + header_len );
SDL_LockMutex (input_buffer_mutex );
command_buffer_enqueue (input , & input_queue_start , & input_queue_end );
SDL_UnlockMutex (input_buffer_mutex );
cmd_len = -1 ;
header_len = 0 ;
readbuf_len = 0 ;
}
}
/* The gameplay stream always gets the first write and read attempt in
* a service pass. Bulk streams then receive one bounded quantum each. */
bool asset_write_pending = false;
if (asset_requests_service (sc , & asset_write_pending )) {
progressed = true;
}
if (!progressed ) {
bool write_pending = output != NULL || asset_write_pending ;
unsigned int timeout = socket_quic_timeout (sc , 20 );
bool ready = socket_wait (sc , true, write_pending , timeout );
socket_quic_service (sc , ready , write_pending );
Face requests bypass typed assets:
/* Loaded or requested */
if (FaceList [num ].flags & FACE_REQUESTED ) {
return ;
}
if (load_gfx_user_face (num )) {
return ;
}
if (image_bmaps [num ].pos != -1 ) {
snprintf (VS (buf ), "%s.png" , image_bmaps [num ].name );
FaceList [num ].name = xstrdup (buf );
FaceList [num ].checksum = image_bmaps [num ].crc32 ;
load_picture_from_pack (num );
} else {
FaceList [num ].flags |= FACE_REQUESTED ;
finish_face_cmd (num , image_bmaps [num ].crc32 , image_bmaps [num ].name );
}
and
size_t len = statbuf .st_size ;
unsigned char * data = xmalloc (len );
len = fread (data , 1 , len , fp );
fclose (fp );
uint32_t newsum = 0 ;
/* Something is wrong... Unlink the file and let it reload. */
if (len == 0 ) {
unlink (buf );
checksum = 1 ;
} else {
/* Checksum check */
newsum = crc32 (1L , data , len );
}
free (data );
if (newsum == checksum ) {
FaceList [facenum ].sprite = sprite_tryload_file (buf , 0 , NULL );
if (FaceList [facenum ].sprite != NULL ) {
return ;
}
}
}
packet_struct * packet = packet_new (SERVER_CMD_ASK_FACE , 16 , 0 );
packet_writer_write_uint16 (packet , facenum );
socket_send_packet (packet );
}
Server places full image bytes in a normal gameplay packet:
void socket_command_ask_face (socket_struct * ns , player * pl , uint8_t * data , size_t len , size_t pos ) {
packet_reader_t reader ;
packet_reader_init_cursor (& reader , data , len , & pos );
uint16_t facenum ;
packet_struct * packet ;
facenum = packet_reader_read_uint16 (& reader );
if (facenum == 0 || facenum >= nrofpixmaps || facesets [0 ].faces [facenum ].data == NULL ) {
return ;
}
packet = packet_new (CLIENT_CMD_IMAGE , 16 , 0 );
packet_debug_data (packet , 0 , "Face ID" );
packet_writer_write_uint32 (packet , facenum );
packet_debug_data (packet , 0 , "Face size" );
packet_writer_write_uint32 (packet , facesets [0 ].faces [facenum ].datalen );
packet_debug_data (packet , 0 , "Face data" );
packet_writer_write_bytes (packet ,
facesets [0 ].faces [facenum ].data ,
facesets [0 ].faces [facenum ].datalen );
socket_send_packet (ns , packet );
Ordered FIFO/framing:
/**
* Write data to socket.
* @param ns
* The socket we are writing to.
*/
void socket_buffer_write (socket_struct * ns ) {
while (ns -> packets != NULL ) {
packet_struct * packet = ns -> packets ;
size_t amt ;
bool success = socket_write (ns -> sc ,
(const void * )(packet -> data + packet -> pos ),
packet -> len - packet -> pos ,
& amt );
if (!success ) {
ns -> state = ST_DEAD ;
break ;
}
packet -> pos += amt ;
HARD_ASSERT (ns -> packet_queue_bytes >= amt );
ns -> packet_queue_bytes -= amt ;
server_metrics_queue_changed (- (int64_t )amt , ns -> packet_queue_bytes , false);
if (packet -> len - packet -> pos == 0 ) {
DL_DELETE (ns -> packets , packet );
HARD_ASSERT (ns -> packet_queue_count != 0 );
ns -> packet_queue_count -- ;
packet_free (packet );
continue ;
}
/* A nonblocking transport made only partial (or no) progress. */
break ;
}
and
void socket_send_packet (socket_struct * ns , struct packet_struct * packet ) {
HARD_ASSERT (ns != NULL );
HARD_ASSERT (packet != NULL );
if (!packet_writer_finish (packet )) {
LOG (ERROR ,
"Refusing malformed outbound packet: %s" ,
packet_error_string (packet_writer_error (packet )));
packet_free (packet );
return ;
}
if (ns -> state == ST_DEAD || ns -> state == ST_ZOMBIE ) {
packet_free (packet );
return ;
}
if (packet -> len + 1 > UINT16_MAX ) {
log_error ("Sending packet with size >%u" , UINT16_MAX );
packet_free (packet );
return ;
}
packet_struct * packet_meta = packet_new (0 , 4 , 0 );
packet_meta -> ndelay = packet -> ndelay ;
packet_compress (packet );
uint32_t payload_len = (uint32_t )packet -> len + 1 ;
if (payload_len < 0x8000 ) {
packet_writer_write_uint16 (packet_meta , (uint16_t )payload_len );
} else {
packet_writer_write_uint8 (packet_meta , (uint8_t )(0x80 | (payload_len >> 16 )));
packet_writer_write_uint16 (packet_meta , (uint16_t )(payload_len & 0xffff ));
}
packet_writer_write_uint8 (packet_meta , packet -> type );
size_t queued_size = packet_meta -> len + packet -> len ;
size_t queued_packets = packet -> len != 0 ? 2 : 1 ;
if (!socket_buffer_can_enqueue (ns , queued_size ) ||
ns -> packet_queue_count > SOCKET_QUEUE_PACKET_LIMIT - queued_packets ) {
ns -> packet_queue_rejected ++ ;
server_metrics_queue_changed (0 , ns -> packet_queue_bytes , true);
LOG (ERROR ,
"Connection %s exceeded its outbound queue limit "
"(queued=%" PRIu64 ", rejected=%" PRIu64 ")" ,
socket_get_id (ns -> sc ),
(uint64_t )ns -> packet_queue_bytes ,
(uint64_t )queued_size );
packet_free (packet_meta );
packet_free (packet );
ns -> state = ST_ZOMBIE ;
return ;
}
socket_packet_enqueue (ns , packet_meta );
if (packet -> len != 0 ) {
socket_packet_enqueue (ns , packet );
} else {
packet_free (packet );
Client waits for the complete frame:
if (readbuf_len > 0 && (readbuf [0 ] & 0x80 )) {
toread = 3 - readbuf_len ;
} else {
toread = 2 - readbuf_len ;
}
} else if (readbuf_len == 2 && (readbuf [0 ] & 0x80 )) {
toread = 1 ;
} else {
if (readbuf_len <= 3 ) {
uint8_t * p = readbuf ;
header_len = (* p & 0x80 ) ? 3 : 2 ;
cmd_len = 0 ;
if (header_len == 3 ) {
cmd_len += ((int )(* p ++ ) & 0x7f ) << 16 ;
}
cmd_len += ((int )(* p ++ )) << 8 ;
cmd_len += ((int )(* p ++ ));
}
toread = cmd_len + header_len - readbuf_len ;
if (readbuf_len + toread > readbuf_size ) {
uint8_t * tmp = readbuf ;
readbuf_size = readbuf_len + toread ;
readbuf = xmalloc (readbuf_size );
memcpy (readbuf , tmp , readbuf_len );
free (tmp );
}
}
size_t amt ;
if (!socket_read (sc , readbuf + readbuf_len , (size_t )toread , & amt )) {
break ;
}
if (amt != 0 ) {
progressed = true;
readbuf_len += (int )amt ;
network_graph_update (NETWORK_GRAPH_TYPE_GAME , NETWORK_GRAPH_TRAFFIC_RX , amt );
if (readbuf_len == cmd_len + header_len && !socket_thread_aborted ()) {
command_buffer * input =
command_buffer_new (readbuf_len - header_len , readbuf + header_len );
SDL_LockMutex (input_buffer_mutex );
command_buffer_enqueue (input , & input_queue_start , & input_queue_end );
SDL_UnlockMutex (input_buffer_mutex );
cmd_len = -1 ;
header_len = 0 ;
readbuf_len = 0 ;
Whole-command main-loop budget:
/** Maximum time spent draining server commands before yielding to rendering. */
#define CLIENT_COMMAND_BUDGET_US UINT64_C(4000)
/** Client player structure with things like stats, damage, etc */
Client_Player cpl ;
/** Structure of all the socket commands */
static socket_command_struct commands [CLIENT_CMD_NROF ] = {
#define ATRINIK_CLIENT_COMMAND_HANDLER (_symbol , _handler ) \
[CLIENT_CMD_ ##_symbol ] = {.handle_func = (_handler ), .name = CLIENT_CMD_NAME_ ##_symbol },
#include "command_handlers.def"
#undef ATRINIK_CLIENT_COMMAND_HANDLER
};
CASSERT_ARRAY (commands , CLIENT_CMD_NROF );
/**
* Do client. The main loop for commands. From this, the data and
* commands from server are received.
*/
void DoClient (void ) {
command_buffer * cmd ;
uint64_t commands_started = datetime_monotonic_us ();
/* Handle all enqueued commands */
while ((cmd = get_next_input_command ()) != NULL ) {
uint8_t * data = cmd -> data ;
size_t len = cmd -> len ;
size_t pos = 0 ;
packet_reader_t reader ;
packet_reader_init_cursor (& reader , data , len , & pos );
uint8_t type = packet_reader_read_uint8 (& reader );
if (packet_reader_error (& reader ) != PACKET_ERROR_NONE ) {
LOG (ERROR , "Rejected command envelope: %s" , packet_error_string (reader .error ));
} else if (type >= CLIENT_CMD_NROF || commands [type ].handle_func == NULL ) {
LOG (ERROR , "Bad command from server (%d)" , type );
} else {
packet_reader_scope_t scope ;
packet_reader_scope_begin (& scope );
packet_reader_init_at (& reader , data , len , pos );
commands [type ].handle_func (data , len , pos );
packet_error_t error = packet_reader_scope_finish (& scope );
if (error != PACKET_ERROR_NONE ) {
LOG (ERROR ,
"Rejected malformed %s command: %s" ,
commands [type ].name ,
packet_error_string (error ));
}
}
command_buffer_free (cmd );
/* A sustained stream of multi-level map updates must not starve the
* main loop's render/present phases. Packet order is retained; the
* remaining queue resumes on the next frame. Always finish at least
* the command already dequeued, even when it exceeds this budget. */
if (datetime_monotonic_us () - commands_started >= CLIENT_COMMAND_BUDGET_US ) {
break ;
Synchronous cache write, decode, and redraw:
void socket_command_image (uint8_t * data , size_t len , size_t pos ) {
uint32_t facenum ;
packet_view_t image ;
char buf [HUGE_BUF ];
if (!client_packet_parse_image (data , len , pos , & facenum , & image )) {
return ;
}
if (!image_face_valid (facenum ) || image_get_face_name (facenum ) == NULL ) {
LOG (ERROR , "Ignoring image packet with invalid face ID %" PRIu32 , facenum );
return ;
}
/* Save picture to cache and load it to FaceList. */
snprintf (buf , sizeof (buf ), DIRECTORY_CACHE "/%s" , image_get_face_name (facenum ));
char * path = file_path (buf , "wb" );
bool saved = path_write_atomic (path , image .data , image .len , 0600 );
free (path );
if (!saved ) {
LOG (ERROR , "Could not atomically write image cache file '%s'." , buf );
return ;
}
FaceList [facenum ].sprite = sprite_tryload_file (buf , 0 , NULL );
map_redraw_flag = minimap_redraw_flag = 1 ;
book_redraw ();
interface_redraw ();
/* TODO: this could be a bit more intelligent to detect whether any of
* these widgets actually contain an object with the updated face. */
WIDGET_REDRAW_ALL (PDOLL_ID );
WIDGET_REDRAW_ALL (QUICKSLOT_ID );
WIDGET_REDRAW_ALL (INVENTORY_ID );
Prior typed-stream foundation: 7949170
Historical tracking issue for that foundation: Isolate and parallelize in-band QUIC asset downloads atrinik#126
Affected build observed through the classic wrapper profile: 7905b9b2b25215689921c283c895bac50ec59a12.
Acceptance criteria
Missing face bytes use typed QUIC asset streams, or an equivalent bounded transfer path isolated from the ordered gameplay FIFO; do not place full PNG bodies in CLIENT_CMD_IMAGE on the game stream.
Face requests remain deduplicated and have explicit admission, size, integrity, cancellation, and retry bounds.
Gameplay remains priority and continues to make progress during cold-cache bursts, loss, and backpressure.
Cache writes and image decode/upload do not cause unbounded main/render-thread stalls; add measurement or budgeting appropriate to the chosen design.
Add an end-to-end cold-cache regression scenario that verifies concurrent face progress and responsive gameplay.
Preserve warm-cache behavior and isolate malformed, oversized, or integrity-failing assets.
Description
On a cold client cache, entering an area containing never-before-seen graphics makes the game very laggy and can freeze character movement until the images arrive. Repeating the same route with a warm cache is much smoother.
Classic does have bounded parallel QUIC asset downloads: up to three typed
SOCKET_STREAM_ASSETstreams are opened, and the network loop prioritizes the gameplay stream before servicing bulk streams. Those streams currently cover the listing/server files, resources, and region maps.On-demand face graphics bypass that machinery. A missing face sends legacy
SERVER_CMD_ASK_FACE; the server embeds the entire PNG inCLIENT_CMD_IMAGEand enqueues it on the long-lived ordered gameplay FIFO. The client network thread cannot enqueue the next game command until that complete framed image command is read. The main/render thread then atomically writes and decodes each PNG synchronously, and its command budget only checks after the whole handler finishes. Cold-cache face bursts can therefore both head-of-line block gameplay traffic and overrun a frame while writing, decoding, and redrawing.This is the gap behind the reported behavior: parallel QUIC exists, but dynamic face/image downloads do not use it.
Steps to reproduce
Expected
Movement, input, map updates, and rendering stay responsive while missing graphics download with bounded concurrency.
Actual
The game visibly lags and movement can stop until cold-cache graphics have streamed in.
Technical notes
classic/libatrinik/socket.h
Lines 211 to 226 in 4653cb0
classic/client/src/client/asset.c
Lines 495 to 545 in 4653cb0
classic/client/src/client/socket.c
Lines 288 to 320 in 4653cb0
classic/client/src/client/image.c
Lines 444 to 461 in 4653cb0
classic/client/src/client/image.c
Lines 280 to 308 in 4653cb0
classic/server/src/socket/image.c
Lines 247 to 268 in 4653cb0
classic/server/src/socket/lowlevel.c
Lines 108 to 143 in 4653cb0
classic/server/src/socket/lowlevel.c
Lines 146 to 205 in 4653cb0
classic/client/src/client/socket.c
Lines 260 to 305 in 4653cb0
classic/client/src/client/client.c
Lines 51 to 109 in 4653cb0
classic/client/src/client/commands.c
Lines 147 to 180 in 4653cb0
Affected build observed through the
classicwrapper profile:7905b9b2b25215689921c283c895bac50ec59a12.Acceptance criteria
CLIENT_CMD_IMAGEon the game stream.