Skip to content

Instantly share code, notes, and snippets.

@skull-squadron
Created December 4, 2025 20:23
Show Gist options
  • Select an option

  • Save skull-squadron/8b1111167de9b0120e2cf1d309014550 to your computer and use it in GitHub Desktop.

Select an option

Save skull-squadron/8b1111167de9b0120e2cf1d309014550 to your computer and use it in GitHub Desktop.

Revisions

  1. skull-squadron created this gist Dec 4, 2025.
    169 changes: 169 additions & 0 deletions json2img
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,169 @@
    #!/usr/bin/env ruby
    # frozen_string_literal: true
    #
    # Converts https://www.pcjs.org json disk image to real disk .img and extract files
    #
    # Usage: json2img [options..] {infile.json|https://path/to/web/disk.json} [OUT_DIR]
    #
    # -v|--verbose verbose
    # -n|--no-op no-op
    #
    #
    # If out_dir is not specified, it will be created based on the input file name.
    #
    #
    # Example:
    #
    # json2img https://diskettes.pcjs.org/pcx86/dev/rom/ibm/5170/IBM-BIOS-SRC-5170V3.json

    require 'json'
    require 'fileutils'
    require 'open-uri'
    require 'digest/md5'

    def warn_verbose(*msg)
    warn(*msg) if VERBOSE
    end

    def write_file(fname, data, noop: false, verbose: false)
    if noop
    warn_verbose "Would write file #{fname} (#{data.bytesize} byte(s))"
    return
    end

    if File.exist?(fname)
    if File.binread(fname) == data
    warn_verbose "File already exists and the same: #{fname}"
    else
    warn "File already exists and differs (skipping): #{fname}"
    end
    return
    end

    FileUtils.mkdir_p(File.dirname(fname), noop: noop, verbose: verbose)
    warn_verbose "Writing file #{fname} (#{data.bytesize} byte(s))"
    File.binwrite(fname, data)
    end

    NOOP = !ENV['NOOP'].nil? | ARGV.delete('-n') | ARGV.delete('--no-op')
    VERBOSE = !ENV['VERBOSE'].nil? | ARGV.delete('-v') | ARGV.delete('--verbose')
    in_file, out_dir = ARGV
    if in_file.nil? || ARGV.delete('-h') || ARGV.delete('--help')
    warn "Usage: #{File.basename($PROGRAM_NAME)} [options..] {infile.json|https://path/to/web/disk.json} [OUT_DIR]"
    warn ''
    warn ' -v|--verbose verbose'
    warn ' -n|--no-op no-op'
    warn ''
    warn ''
    warn ' If out_dir is not specified, it will be created based on the input file name.'
    warn ''
    warn ''
    exit 1
    end

    in_file_prefix = File.basename(in_file, '.json')

    out_dir ||= in_file_prefix

    if in_file =~ /\A[a-zA-Z](?:[a-zA-Z0-9+.-])*:\/\/.+/
    warn_verbose "Downloading remote file #{in_file}"
    in_file = URI.open(in_file)
    end

    j = JSON.parse(in_file.read)

    size = j['imageInfo']['diskSize']
    cylinders = j['imageInfo']['cylinders']
    heads = j['imageInfo']['heads']
    sectors_per_track = j['imageInfo']['trackDefault']
    bytes_per_sector = j['imageInfo']['sectorDefault']

    unless size == cylinders * heads * sectors_per_track * bytes_per_sector
    raise 'C * H * S * bytes_per_sector != size'
    end

    disk_img = "\0".b * size

    files = j['fileTable'][1..].map do |f|
    {
    'filename' => f['path'].sub(/\A\//, ''),
    'size' => f['size'],
    'data' => "\0".b * f['size'],
    }
    end

    j['diskData'].each do |pieces|
    pieces.flatten.each do |sec|
    c, h, s, l = sec['c'], sec['h'], (sec['s'] - 1), sec['l']

    raise 'Bad individual sector != sector size' unless l == bytes_per_sector

    # decode sector

    sector = sec['d'].pack('l<*')
    next if sector.bytes.all?(&:zero?)

    pad_sz = bytes_per_sector - sector.bytesize
    sector.append_as_bytes(sector[-4..-1] * (pad_sz/4)) unless pad_sz.zero?
    raise 'sector incorrect size' unless sector.bytesize == bytes_per_sector

    # write sector

    img_off = ((c * heads + h) * sectors_per_track + s) * bytes_per_sector
    img_end = img_off + bytes_per_sector - 1
    disk_img[img_off..img_end] = sector

    # write file parts

    f_off = sec['o']
    next if f_off.nil?

    f = files[sec['f'] - 1]

    f_sz = f['size']

    raise "f_off #{f_off} > f_sz #{f_sz}" if f_off > f_sz

    f_end = f_off + bytes_per_sector
    overrun = f_end - f_sz
    bin_end = bytes_per_sector - 1
    if overrun > 0
    bin_end -= overrun
    f_end = f_sz
    end

    f['data'][f_off..f_end] = sector[0..bin_end]
    end
    end

    # check file integrity
    mismatch = false
    j['fileTable'].each_with_index do |f, idx|
    expected_size = f['size']
    next unless expected_size

    data = files[idx-1]['data']
    actual_size = data.bytesize

    expected_md5 = f['hash']
    actual_md5 = Digest::MD5.hexdigest(data)

    if expected_size == actual_size && expected_md5 == actual_md5
    warn_verbose "file OK: #{f['path']} (size: #{size} byte(s), md5 #{actual_md5})"
    else
    warn "file mismatch: #{f['path']} (expected_md5: #{expected_md5}, actual_md5: #{actual_md5}; expected_size: #{expected_size}, actual_size: #{actual_size})"
    mismatch = true
    end
    end
    exit 1 if mismatch

    # write files

    root_dir = j['fileTable'][0]['path']&.sub(/\A\//, '') || in_file_prefix
    write_file("#{out_dir}/#{in_file_prefix}.img", disk_img, noop: NOOP, verbose: VERBOSE)

    files.each do |f|
    filename = "#{out_dir}/#{root_dir}/#{f['filename']}"
    data = f['data']
    write_file(filename, data, noop: NOOP, verbose: VERBOSE)
    end