From 59de8aa82085a32c26ddc4dc7b4230cd2abe0b76 Mon Sep 17 00:00:00 2001 From: Luke Hubmayer-Werner Date: Wed, 17 Jul 2024 22:03:27 +0930 Subject: [PATCH] [BGM] Fix incorrect SFX data, change tie handling to accomodate weird edge cases --- data/5/addresses_SNES_PSX.tsv | 41 +++++++++++----- scripts/MusicRenderer.gd | 87 ++++++++++++++++++++------------- scripts/loaders/SoundLoader.gd | 8 +-- scripts/struct.gd | 7 ++- shaders/audio_renderer.gdshader | 11 +++-- test/audio_renderer.gd | 2 +- 6 files changed, 94 insertions(+), 62 deletions(-) diff --git a/data/5/addresses_SNES_PSX.tsv b/data/5/addresses_SNES_PSX.tsv index bfe7133..734e65d 100644 --- a/data/5/addresses_SNES_PSX.tsv +++ b/data/5/addresses_SNES_PSX.tsv @@ -11,12 +11,16 @@ locations_bg_palettes 0x03BB00 /nar/ff5_binx.bin 0x03BF80 43 of 128 of ColorBGR5 font_glyphs_dialogue 0x03E800 256 of SNESTritile length 0x1800 spc_note_durations 0x041D7E 14 of u8 bytelength_sfx_brr_data 0x041E3F u16 Used by the memcpy routine that copies the below data to the SPC (0x010E = 270 bytes = 16 BRR packets = 480 samples) -sfx_brr_data 0x041E41 Use the below SPC pointers +sfx_brr_data 0x041E41 270 of u8 Use the below SPC pointers bytelength_sfx_brr_pointers 0x041F4F u16 Used by the memcpy routine that copies the below data to the SPC (0x0020 = 32 bytes) sfx_brr_pointers 0x041F51 8 of 2 of u16 SPC memory addresses not ROM. Start address followed by loop address. -sfx_adsrs 0x041F71 8 of 4 of u4 -sfx_samplerates 0x041F83 8 of u16 -sfx_data 0x041F95 Contains SPC pointers and tracks +bytelength_sfx_adsrs 0x041F71 u16 0x0010 = 16 bytes +sfx_adsrs 0x041F73 8 of 4 of u4 +bytelength_sfx_samplerates 0x041F83 u16 0x0010 = 16 bytes +sfx_samplerates 0x041F85 8 of u16 +bytelength_sfx_data 0x041F95 u16 0x1C00 = 7168 bytes +sfx_sequence_pointers 0x041F97 256 of 2 of u16 Contains SPC pointers. Subtract 0x3000 to get position within next block. Each SFX sequence has two channels. +sfx_sequences 0x042397 6144 of u8 0x1800 = 6144 bytes bgm_song_pointers 0x043B97 72 of u24 bgm_instrument_brr_pointers 0x043C6F 35 of u24 bgm_instrument_loop_starts 0x043CD8 35 of u16 @@ -27,16 +31,19 @@ bgm_instrument_indices 0x043DAA 72 of 16 of u16 length 0x900 worldmap_compressed_tilesets 0x070000 tilesets 0 up to 0x434 worldmap_compressed_tilesets2 0x080000 tilesets 0x434 up to 0x500 ptrs_jp_speech 0x082220 2160 of u16 -ptrs_extended_event_data 0x083320 1940 of u24 -extended_event_data 0x0849DC See above for addresses +ptrs_event_scripts 0x083320 1940 of u24 +event_scripts 0x0849DC See above for addresses jp_speech 0x0A0000 See 0x082220 for offsets ptrs_tilemaps 0x0B0000 328 of u16 tilemaps 0x0B0290 See above for offsets -ptrs_npc_actions 0x0E0000 928 of u16 -npc_actions 0x0E0740 See above for offsets -ptrs_event_places 0x0E2400 512 of u16 -event_places 0x0E2800 920 of EventPlace See above for offsets +map_palette_animation 0x0DFA40 15 of 24 of u8 Need new struct +map_palette_animation_colors 0x0DFBA8 44 of ColorBGR555 + +ptrs_npc_scripts 0x0E0000 928 of u16 +npc_scripts 0x0E0740 See above for offsets +ptrs_event_triggers 0x0E2400 512 of u16 +event_triggers 0x0E2800 920 of EventPlace See above for offsets ptrs_zone_exits 0x0E36C0 512 of u16 zone_exits 0x0E3AC0 See above for offsets ptrs_npc_data 0x0E59C0 512 of u16 @@ -50,7 +57,7 @@ ptrs_tile_properties 0x0FC540 23 of u16 tile_properties 0x0FC56E See above for offsets worldmap_minimap_border_tiles 0x0FD800 32 of TileSNES4bpp length 0x400 ptrs_worldmap_tilesets 0x0FE000 5 of 256 of u16 Every offset points to a horizontal line of 256 tiles stored in banks C7 and C8 -worldmap_block_properties 0x0FEA00 3 of 192 of WorldMapBlockProperties +worldmap_block_properties 0x0FEA00 3 of 192 of WorldMapBlockProperties worldmap_blocks 0x0FF0C0 /nar/ff5_binx.bin 0x040300 3 of 4 of 192 of u8 # Top-left corners, top-right corners, bottom-left corners, bottom-right corners worldmap_tiles.bias 0x0FF9C0 /nar/ff5_bin3.bin 0x03FB00 3 of 256 of u8 Add to each pixel of the mode7c tiles worldmap_palettes 0x0FFCC0 /nar/ff5_binx.bin 0x040000 3 of 128 of ColorBGR555 @@ -125,8 +132,9 @@ ptrs_battle_background_tileset_skips 0x184157 21 of u24 RAM addresses, subtrac ptrs_battle_background_tilesets 0x184196 21 of u24 ROM addresses, subtract 0xC00000 lzss_battle_background_tilesets 0x1841D5 see pointers above, 4bpp ? 0x18DE36 -ptrs_event_data 0x18E080 687 of u16 -event_data 0x18E5E0 see above, links to extended event data +initial_npc_flags 0x18E000 128 of u8 +ptrs_trigger_scripts 0x18E080 704 of u16 offset from this same address +trigger_scripts 0x18E600 see above, links to event scripts too tiles_attack_anims 0x190000 ptrs_anim_unk1 0x19A486 405 of u16 bank offsets to below 0x19A7B0 padding @@ -139,4 +147,11 @@ worldmap_tiles.1 0x1BA000 /nar/ff5_bin3.bin 0x039B00 256 of TileSNESMode7c Add t worldmap_tiles.2 0x1BC000 /nar/ff5_bin3.bin 0x039B00 128 of TileSNESMode7c Add the biases font_glyphs_kanji 0x1BD000 426 of SNESTritile length of 0x27F0 ? 0x1BF800 +ptrs_map_bg3_graphics 0x1C0000 18 of u16 From start of region (0x1C0024) +map_bg3_graphics 0x1C0024 2bpp +ptrs_map_graphics 0x1C2D84 40 of u32 From start of region (0x1C2E24) +map_graphics 0x1C2E24 4bpp +map_animation_graphics 0x1F9B00 +map_palettes 0x1FFC00 32 of 16 of ColorBGR555 + RPGe_font_character_widths 0x203225 512 of u8 RPGe only, Includes the 1px spacing diff --git a/scripts/MusicRenderer.gd b/scripts/MusicRenderer.gd index 9a37952..cb181d6 100644 --- a/scripts/MusicRenderer.gd +++ b/scripts/MusicRenderer.gd @@ -6,7 +6,8 @@ var MUSIC := music.new() const NUM_TRACKS := 8 # TODO const MAX_NOTE_EVENTS := 2048 class NoteEvent: - var p_start: int # In pulse space + var p_event_start: int # In pulse space + var p_note_start: int # For tied notes, this will be earlier than p_event_start and is used for envelope calculations var p_end: int var instrument: int var pitch: int @@ -94,13 +95,13 @@ class TrackCurve: # built-in Curve class is too restrictive for this return 0.0 -static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none') -> Array: # [data: PoolByteArray, target_time_length: float in seconds] +static func render_channels(tracks: Array, inst_map: Array, _debug_name := 'none') -> Array: # [data: PoolByteArray, target_time_length: float in seconds] # Since some channels contain global events (tempo and global volume for now), # the strategy will be to preprocess each channel in a global-state-agnostic way, # then once all the global tracks are known, as well as the longest unlooped length, # do a second pass to generate the final events # self.print_channel_events(inst_map) - var instrument_adsrs = RomLoader.snes_data.bgm_instrument_adsrs # TODO: UNHARDCODE THIS + var sample_default_adsrs = RomLoader.snes_data.sfx_adsrs + RomLoader.snes_data.bgm_instrument_adsrs # TODO: UNHARDCODE THIS var all_note_events = [] var curve_master_volume := TrackCurve.new(100.0/255.0) # [0.0, 1.0] for now @@ -157,6 +158,8 @@ static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none' infinite_loop_target_program_counter = track[-1][1] var program_counter := 0 + var last_note_pretransform_pitch := -2 + var last_untied_note_p_start := 0 while true: #num_notes < MAX_NOTE_EVENTS: if program_counter >= l: break @@ -167,8 +170,8 @@ static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none' match event[0]: # Control codes EventType.GOTO: # This is a preprocessed event list, so GOTO is a final infinite loop marker var note_event = NoteEvent.new() - note_event.p_start = p - note_event.p_end = infinite_loop_target_pulse # Fake final note event using p_start > p_end to encode the infinite jump back loop. + note_event.p_event_start = p + note_event.p_end = infinite_loop_target_pulse # Fake final note event using p_event_start > p_end to encode the infinite jump back loop. # Note that event[1] points to an Event, not a NoteEvent, not a Pulse, so we looked it up earlier channel_note_events.append(note_event) break @@ -187,9 +190,12 @@ static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none' var note = event[1] var duration = event[2] if note >= 0: # Don't shift or play rests + last_note_pretransform_pitch = note # Ties reuse this + last_untied_note_p_start = p note += (12 * current_octave) + current_transpose var note_event = NoteEvent.new() - note_event.p_start = p + note_event.p_event_start = p + note_event.p_note_start = p note_event.p_end = p + duration note_event.instrument = current_instrument note_event.pitch = note # pitch_idx #* curve_fine_tuning @@ -201,12 +207,20 @@ static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none' channel_note_events.append(note_event) # num_notes += 1 elif note == music.NOTE_IS_TIE: - if not channel_note_events: - print('Encountered a tie with no preceeding note! %s channel %d pulse %d (loop return is %d)' % [debug_name, channel, p, infinite_loop_target_pulse]) - else: - if channel_note_events[-1].p_end != p: - print('Encountered a tie with preceeding rest! %s channel %d pulse %d (loop return is %d)' % [debug_name, channel, p, infinite_loop_target_pulse]) - channel_note_events[-1].p_end += duration + if last_note_pretransform_pitch >= 0: + note = last_note_pretransform_pitch + (12 * current_octave) + current_transpose + var note_event = NoteEvent.new() + note_event.p_event_start = p + note_event.p_note_start = last_untied_note_p_start + note_event.p_end = p + duration + note_event.instrument = current_instrument + note_event.pitch = note # pitch_idx #* curve_fine_tuning + note_event.velocity = curve_velocity.get_pulse(p) # current_velocity + note_event.adsr_attack_rate = current_adsr_attack_rate + note_event.adsr_decay_rate = current_adsr_decay_rate + note_event.adsr_sustain_level = current_adsr_sustain_level + note_event.adsr_sustain_rate = current_adsr_sustain_rate + channel_note_events.append(note_event) p += duration EventType.VOLUME: var new_velocity: float = event[1]/255.0 @@ -248,18 +262,18 @@ static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none' scale = fine_tune/255.0 curve_fine_tuning.add_point(p, scale) EventType.PROGCHANGE: - var event_idx = event[1]-0x20 - if event_idx >= 0: - current_instrument = inst_map[event_idx] - 1 - if current_instrument < len(instrument_adsrs) and current_instrument > 0: - var adsr = instrument_adsrs[current_instrument] - current_adsr_attack_rate = adsr[0] - current_adsr_decay_rate = adsr[1] - current_adsr_sustain_level = adsr[2] - current_adsr_sustain_rate = adsr[3] + current_instrument = event[1] + if current_instrument >= 0x20: + current_instrument = inst_map[current_instrument-0x20] - 1 + SoundLoader.SFX_NUM + if current_instrument < len(sample_default_adsrs) and current_instrument > 0: + var adsr = sample_default_adsrs[current_instrument] + current_adsr_attack_rate = adsr[0] + current_adsr_decay_rate = adsr[1] + current_adsr_sustain_level = adsr[2] + current_adsr_sustain_rate = adsr[3] EventType.ADSR_DEFAULT: # TODO - Investigate actual scaling and order - if current_instrument < len(instrument_adsrs) and current_instrument > 0: - var adsr = instrument_adsrs[current_instrument] + if current_instrument < len(sample_default_adsrs) and current_instrument > 0: + var adsr = sample_default_adsrs[current_instrument] current_adsr_attack_rate = adsr[0] current_adsr_decay_rate = adsr[1] current_adsr_sustain_level = adsr[2] @@ -339,13 +353,13 @@ static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none' continue var note_event: NoteEvent = all_note_events[channel][-1] var p_end = note_event.p_end - if p_end < note_event.p_start: + if p_end < note_event.p_event_start: # Ends on infinite loop channel_loop_p_returns.append(p_end) - channel_loop_p_lengths.append(note_event.p_start - p_end) + channel_loop_p_lengths.append(note_event.p_event_start - p_end) if p_end > highest_channel_p_return: highest_channel_p_return = p_end - p_end = note_event.p_start + p_end = note_event.p_event_start else: channel_loop_p_returns.append(-1) channel_loop_p_lengths.append(0) @@ -365,7 +379,8 @@ static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none' var loop_return_p = channel_loop_p_returns[channel] var curve_pan: TrackCurve = curve_channel_pans[channel] - var midi_events_bytes_t_start := StreamPeerBuffer.new() + var midi_events_bytes_t_event_start := StreamPeerBuffer.new() + var midi_events_bytes_t_note_start := StreamPeerBuffer.new() var midi_events_bytes_t_end := StreamPeerBuffer.new() var midi_events_bytes3 := StreamPeerBuffer.new() var midi_events_bytes_adsr := StreamPeerBuffer.new() @@ -381,10 +396,11 @@ static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none' event_ptr = loop_return_note_event_idx loop_p_offset += channel_loop_p_lengths[channel] var event: NoteEvent = events[event_ptr] - var p = event.p_start + var p = event.p_event_start if loop_return_note_event_idx < 0 and p >= loop_return_p: loop_return_note_event_idx = event_ptr - midi_events_bytes_t_start.put_32(int(curve_master_tempo.get_integral(p + loop_p_offset) * 32000)) + midi_events_bytes_t_event_start.put_32(int(curve_master_tempo.get_integral(p + loop_p_offset) * 32000)) + midi_events_bytes_t_note_start.put_32(int(curve_master_tempo.get_integral(event.p_note_start + loop_p_offset) * 32000)) midi_events_bytes_t_end.put_32(int(curve_master_tempo.get_integral(event.p_end + loop_p_offset) * 32000)) # t_end midi_events_bytes3.put_u8(event.instrument) midi_events_bytes3.put_u8(event.pitch) @@ -399,11 +415,12 @@ static func render_channels(tracks: Array, inst_map: Array, debug_name := 'none' num_notes += 1 # Fill up end of notes array with dummies for i in range(num_notes, MAX_NOTE_EVENTS): - midi_events_bytes_t_start.put_32(0x0FFFFFFF) + midi_events_bytes_t_event_start.put_32(0x0FFFFFFF) + midi_events_bytes_t_note_start.put_32(0x0FFFFFFF) midi_events_bytes_t_end.put_32(0x0FFFFFFF) midi_events_bytes3.put_32(0) midi_events_bytes_adsr.put_32(0) - data += midi_events_bytes_t_start.data_array + midi_events_bytes_t_end.data_array + midi_events_bytes3.data_array + midi_events_bytes_adsr.data_array + data += midi_events_bytes_t_event_start.data_array + midi_events_bytes_t_end.data_array + midi_events_bytes3.data_array + midi_events_bytes_adsr.data_array + midi_events_bytes_t_note_start.data_array var smp_loop_start = -1 var smp_loop_end = -1 if highest_channel_p_return > 0: @@ -431,11 +448,11 @@ static func disassemble_channel_events(channel_events: Array, inst_map: Array) - output.append(print_str + print_str2) p += duration EventType.PROGCHANGE: - var event_idx = event[1]-0x20 - if event_idx >= 0: - output.append(print_str + 'instrument %02d'%(inst_map[event_idx] - 1)) + var event_idx = event[1] + if event_idx >= 0x20: + output.append(print_str + '($%02x) = instrument %02d'%[event_idx, inst_map[event_idx-0x20] - 1]) else: - output.append(print_str + print_str2) + output.append(print_str + 'sfx %d'%event_idx) _: output.append(print_str + print_str2) return output diff --git a/scripts/loaders/SoundLoader.gd b/scripts/loaders/SoundLoader.gd index a051882..08f4187 100644 --- a/scripts/loaders/SoundLoader.gd +++ b/scripts/loaders/SoundLoader.gd @@ -148,7 +148,7 @@ func load_sfx_samples_data(snes_data: Dictionary, buffer: StreamPeerBuffer): for two_of_u16 in snes_data.sfx_brr_pointers: brr_spc_addrs.append(two_of_u16[0]) brr_spc_loop_addrs.append(two_of_u16[1]) - var brr_spc_start = Common.SNES_PSX_addresses.sfx_brr_data.SNES - brr_spc_addrs[0] # Refactor this later + var brr_spc_start = Common.SNES_PSX_addresses.sfx_brr_data.SNES - brr_spc_addrs[0] # Refactor this later - SFX samples start at $4800 in ARAM, right after the SFX tracks for i in SFX_NUM: buffer.seek(brr_spc_addrs[i] + brr_spc_start) # print('Loading sfx sample #%X with BRR data offset $%06X' % [i, buffer.get_position()]) @@ -156,7 +156,7 @@ func load_sfx_samples_data(snes_data: Dictionary, buffer: StreamPeerBuffer): var audio := make_sample(buffer, 900, sample_rate) var loop_start_packet: int = brr_spc_loop_addrs[i] - brr_spc_addrs[i] audio.loop_mode = AudioStreamSample.LOOP_FORWARD - audio.loop_begin = loop_start_packet * 16 # Each 9byte packet is 16 samples + audio.loop_begin = (loop_start_packet/9) * 16 # Each 9byte packet is 16 samples audio.loop_end = (len(audio.data)/2) sfx_samples.append(audio) # Use 900 as a limit, it won't be hit, parser stops after End packet anyway emit_signal('audio_sfx_sample_loaded', i) @@ -186,14 +186,14 @@ var samples_tex: ImageTexture const TEX_WIDTH := 2048 const FILTER_PAD := 32 func samples_to_texture(): - var num_samples := INST_NUM + SFX_NUM + var num_samples := SFX_NUM + INST_NUM var header_length := num_samples * 5 # Create header and unwrapped payload separately first var header_buffer := StreamPeerBuffer.new() var payload_buffer := StreamPeerBuffer.new() - for sample in instrument_samples + sfx_samples: + for sample in sfx_samples + instrument_samples: var sample_data_start: int = header_length + (payload_buffer.get_position()/2) + FILTER_PAD # After the prepended silence, in texels (2bytes) var loop_begin: int = sample.loop_begin var loop_length: int = sample.loop_end - loop_begin diff --git a/scripts/struct.gd b/scripts/struct.gd index 5bdaadc..ee1f270 100644 --- a/scripts/struct.gd +++ b/scripts/struct.gd @@ -118,11 +118,10 @@ class StructArrayType extends StructType: func get_value(buffer: StreamPeer, leftover_bits: Array): # Might be a bit too much branching but oh well if self.contained_struct_type is U8: - var result = PoolByteArray() # Slight optimization over calling the method - for i in self.count: - result.append(buffer.get_u8()) - return result + var result = buffer.get_data(self.count) + # result[0] is an error code + return result[1] var result = [] for i in self.count: diff --git a/shaders/audio_renderer.gdshader b/shaders/audio_renderer.gdshader index ae9c987..79bd5cf 100644 --- a/shaders/audio_renderer.gdshader +++ b/shaders/audio_renderer.gdshader @@ -172,7 +172,7 @@ highp float get_instrument_sample(highp float instrument_index, highp float note } // const int ATTACK_TIME_MS[16] = {4100, 2600, 1500, 1000, 640, 380, 260, 160, 96, 64, 40, 24, 16, 10, 6, 0}; -const int ATTACK_TIME_SMPS[16] = {131200, 83200, 48000, 32000, 20480, 12160, 8320, 5120, 3072, 2048, 1280, 768, 512, 320, 192, 1}; +const int ATTACK_TIME_SMPS[16] = {131200, 83200, 48000, 32000, 20480, 12160, 8320, 5120, 3072, 2048, 1280, 768, 512, 320, 192, 0}; // const int DECAY_TIME_MS[8] = {1200, 740, 440, 290, 180, 110, 74, 37}; const int DECAY_TIME_SMPS[8] = {38400, 23680, 14080, 9280, 5760, 3520, 2368, 1184}; // const int SUSTAIN_DECAY_TIME_MS[32] = {Infinite, 38000, 28000, 24000, 19000, 14000, 12000, 9400, 7100, 5900, 4700, 3500, 2900, 2400, 1800, 1500, 1200, 880, 740, 590, 440, 370, 290, 220, 180, 150, 110, 92, 74, 55, 37, 18}; @@ -202,7 +202,7 @@ highp vec4 render_song(highp sampler2D tex, highp int smp) { // Binary search the channels for (int channel = 0; channel < NUM_CHANNELS; channel++) { - highp float row = float(channel * 4); + highp float row = float(channel * 5); highp float event_idx = 0.0; highp int smp_start; for (int i = 0; i < NUM_CHANNEL_NOTE_PROBES; i++) { @@ -212,6 +212,7 @@ highp vec4 render_song(highp sampler2D tex, highp int smp) { } smp_start = retime_smp(int(unpack_int32(get_midi_texel(tex, event_idx, row)))); highp int smp_end = retime_smp(int(unpack_int32(get_midi_texel(tex, event_idx, row+1.0)))); + highp int smp_note_start = retime_smp(int(unpack_int32(get_midi_texel(tex, event_idx, row+4.0)))); highp vec4 note_event_supplement = get_midi_texel(tex, event_idx, row+2.0); // left as [0.0, 1.0] highp float instrument_idx = trunc(note_event_supplement.x * 255.0); @@ -228,12 +229,12 @@ highp vec4 render_song(highp sampler2D tex, highp int smp) { highp int smp_decay = DECAY_TIME_SMPS[adsr.y]; // For now, just branch this - if (smp_start < smp) { // First sample may not start at zero! + if (smp_note_start < smp) { // First sample may not start at zero! highp int smp_overrun = smp - smp_end; // 256 samples of linear decay to 0 after note_off smp_overrun = (smp_overrun < 0) ? 0 : smp_overrun; if (smp_overrun < 256) { - highp int smp_progress = smp - smp_start; - highp float t_start = float(smp_start)/output_mixrate; + highp int smp_progress = smp - smp_note_start; + highp float t_start = float(smp_note_start)/output_mixrate; highp float attack_factor = clamp(float(smp_progress)/float(smp_attack), 0.0, 1.0); highp float decay_factor = mix(1.0, sustain_level, clamp(float(smp_progress - smp_attack)/float(smp_decay), 0.0, 1.0)); highp float sustain_decay_factor = 1.0; diff --git a/test/audio_renderer.gd b/test/audio_renderer.gd index 342ad1e..ddbfce4 100644 --- a/test/audio_renderer.gd +++ b/test/audio_renderer.gd @@ -161,7 +161,7 @@ func push_image(image: Image, target_samples: int, key: String, enqueue: bool = func push_bytes(data: PoolByteArray, target_samples: int, key: String, enqueue: bool = true) -> void: var rows = int(pow(2, ceil(log((len(data)/INPUT_BYTES_PER_TEXEL) / INPUT_TEX_WIDTH)/log(2)))) - var target_length = rows * INPUT_BYTES_PER_TEXEL * INPUT_FORMAT + var target_length = rows * INPUT_BYTES_PER_TEXEL * INPUT_TEX_WIDTH while len(data) < target_length: # This is inefficient, but this function should be called with pre-padded data anyway data.append(0) var image := Image.new()